blob: b457e46a16c48849362deda1aeb20473353ea21f [file] [log] [blame]
#!/usr/bin/env python3
#
# Copyright 2022 The Fuchsia Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import os
import sys
from logging import FileHandler
from logging import Handler
from logging import StreamHandler
from logging.handlers import RotatingFileHandler
from antlion import context
from antlion.context import ContextLevel
from antlion.event import event_bus
from antlion.event.decorators import subscribe_static
# yapf: disable
class LogStyles:
NONE = 0x00
LOG_DEBUG = 0x01
LOG_INFO = 0x02
LOG_WARNING = 0x04
LOG_ERROR = 0x08
LOG_CRITICAL = 0x10
DEFAULT_LEVELS = LOG_DEBUG + LOG_INFO + LOG_ERROR
ALL_LEVELS = LOG_DEBUG + LOG_INFO + LOG_WARNING + LOG_ERROR + LOG_CRITICAL
MONOLITH_LOG = 0x0100
TESTCLASS_LOG = 0x0200
TESTCASE_LOG = 0x0400
TO_STDOUT = 0x0800
TO_ACTS_LOG = 0x1000
ROTATE_LOGS = 0x2000
ALL_FILE_LOGS = MONOLITH_LOG + TESTCLASS_LOG + TESTCASE_LOG
LEVEL_NAMES = {
LOG_DEBUG: 'debug',
LOG_INFO: 'info',
LOG_WARNING: 'warning',
LOG_ERROR: 'error',
LOG_CRITICAL: 'critical',
}
LOG_LEVELS = [
LOG_DEBUG,
LOG_INFO,
LOG_WARNING,
LOG_ERROR,
LOG_CRITICAL,
]
LOG_LOCATIONS = [
TO_STDOUT,
TO_ACTS_LOG,
MONOLITH_LOG,
TESTCLASS_LOG,
TESTCASE_LOG
]
LEVEL_TO_NO = {
LOG_DEBUG: logging.DEBUG,
LOG_INFO: logging.INFO,
LOG_WARNING: logging.WARNING,
LOG_ERROR: logging.ERROR,
LOG_CRITICAL: logging.CRITICAL,
}
LOCATION_TO_CONTEXT_LEVEL = {
MONOLITH_LOG: ContextLevel.ROOT,
TESTCLASS_LOG: ContextLevel.TESTCLASS,
TESTCASE_LOG: ContextLevel.TESTCASE
}
# yapf: enable
_log_streams = dict()
_null_handler = logging.NullHandler()
@subscribe_static(context.NewContextEvent)
def _update_handlers(event):
for log_stream in _log_streams.values():
log_stream.update_handlers(event)
event_bus.register_subscription(_update_handlers.subscription)
def create_logger(name, log_name=None, base_path='', subcontext='',
log_styles=LogStyles.NONE, stream_format=None,
file_format=None):
"""Creates a Python Logger object with the given attributes.
Creation through this method will automatically manage the logger in the
background for test-related events, such as TestCaseBegin and TestCaseEnd
Events.
Args:
name: The name of the LogStream. Used as the file name prefix.
log_name: The name of the underlying logger. Use LogStream name as
default.
base_path: The base path used by the logger.
subcontext: Location of logs relative to the test context path.
log_styles: An integer or array of integers that are the sum of
corresponding flag values in LogStyles. Examples include:
>>> LogStyles.LOG_INFO + LogStyles.TESTCASE_LOG
>>> LogStyles.ALL_LEVELS + LogStyles.MONOLITH_LOG
>>> [LogStyles.DEFAULT_LEVELS + LogStyles.MONOLITH_LOG]
>>> LogStyles.LOG_ERROR + LogStyles.TO_ACTS_LOG]
stream_format: Format used for log output to stream
file_format: Format used for log output to files
"""
if name in _log_streams:
_log_streams[name].cleanup()
log_stream = _LogStream(name, log_name, base_path, subcontext, log_styles,
stream_format, file_format)
_set_logger(log_stream)
return log_stream.logger
def _set_logger(log_stream):
_log_streams[log_stream.name] = log_stream
return log_stream
class AlsoToLogHandler(Handler):
"""Logs a message at a given level also to another logger.
Used for logging messages at a high enough level to the main log, or another
logger.
"""
def __init__(self, to_logger=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self._log = logging.getLogger(to_logger)
def emit(self, record):
self._log.log(record.levelno, record.getMessage())
class MovableFileHandler(FileHandler):
"""FileHandler implementation that allows the output file to be changed
during operation.
"""
def set_file(self, file_name):
"""Set the target output file to file_name.
Args:
file_name: path to the new output file
"""
self.baseFilename = os.path.abspath(file_name)
if self.stream is not None:
new_stream = self._open()
# An atomic operation redirects the output and closes the old file
os.dup2(new_stream.fileno(), self.stream.fileno())
self.stream = new_stream
class MovableRotatingFileHandler(RotatingFileHandler):
"""RotatingFileHandler implementation that allows the output file to be
changed during operation. Rotated files will automatically adopt the newest
output path.
"""
set_file = MovableFileHandler.set_file
class InvalidStyleSetError(Exception):
"""Raised when the given LogStyles are an invalid set."""
class _LogStream(object):
"""A class that sets up a logging.Logger object.
The LogStream class creates a logging.Logger object. LogStream is also
responsible for managing the logger when events take place, such as
TestCaseEndedEvents and TestCaseBeginEvents.
Attributes:
name: The name of the LogStream.
logger: The logger created by this LogStream.
base_path: The base path used by the logger. Use logging.log_path
as default.
subcontext: Location of logs relative to the test context path.
stream_format: Format used for log output to stream
file_format: Format used for log output to files
"""
def __init__(self, name, log_name=None, base_path='', subcontext='',
log_styles=LogStyles.NONE, stream_format=None,
file_format=None):
"""Creates a LogStream.
Args:
name: The name of the LogStream. Used as the file name prefix.
log_name: The name of the underlying logger. Use LogStream name
as default.
base_path: The base path used by the logger. Use logging.log_path
as default.
subcontext: Location of logs relative to the test context path.
log_styles: An integer or array of integers that are the sum of
corresponding flag values in LogStyles. Examples include:
>>> LogStyles.LOG_INFO + LogStyles.TESTCASE_LOG
>>> LogStyles.ALL_LEVELS + LogStyles.MONOLITH_LOG
>>> [LogStyles.DEFAULT_LEVELS + LogStyles.MONOLITH_LOG]
>>> LogStyles.LOG_ERROR + LogStyles.TO_ACTS_LOG]
stream_format: Format used for log output to stream
file_format: Format used for log output to files
"""
self.name = name
if log_name is not None:
self.logger = logging.getLogger(log_name)
else:
self.logger = logging.getLogger(name)
# Add a NullHandler to suppress unwanted console output
self.logger.addHandler(_null_handler)
self.logger.propagate = False
self.base_path = base_path or getattr(logging, 'log_path',
'/tmp/acts_logs')
self.subcontext = subcontext
context.TestContext.add_base_output_path(self.logger.name, self.base_path)
context.TestContext.add_subcontext(self.logger.name, self.subcontext)
self.stream_format = stream_format
self.file_format = file_format
self._testclass_handlers = []
self._testcase_handlers = []
if not isinstance(log_styles, list):
log_styles = [log_styles]
self.__validate_styles(log_styles)
for log_style in log_styles:
self.__handle_style(log_style)
@staticmethod
def __validate_styles(_log_styles_list):
"""Determines if the given list of styles is valid.
Terminology:
Log-level: any of [DEBUG, INFO, WARNING, ERROR, CRITICAL].
Log Location: any of [MONOLITH_LOG, TESTCLASS_LOG,
TESTCASE_LOG, TO_STDOUT, TO_ACTS_LOG].
Styles are invalid when any of the below criteria are met:
A log-level is not set within an element of the list.
A log location is not set within an element of the list.
A log-level, log location pair appears twice within the list.
A log-level has both TESTCLASS and TESTCASE locations set
within the list.
ROTATE_LOGS is set without MONOLITH_LOG,
TESTCLASS_LOG, or TESTCASE_LOG.
Raises:
InvalidStyleSetError if the given style cannot be achieved.
"""
def invalid_style_error(message):
raise InvalidStyleSetError('{LogStyle Set: %s} %s' %
(_log_styles_list, message))
# Store the log locations that have already been set per level.
levels_dict = {}
for log_style in _log_styles_list:
for level in LogStyles.LOG_LEVELS:
if log_style & level:
levels_dict[level] = levels_dict.get(level, LogStyles.NONE)
# Check that a log-level, log location pair has not yet
# been set.
for log_location in LogStyles.LOG_LOCATIONS:
if log_style & log_location:
if log_location & levels_dict[level]:
invalid_style_error(
'The log location %s for log level %s has '
'been set multiple times' %
(log_location, level))
else:
levels_dict[level] |= log_location
# Check that for a given log-level, not more than one
# of MONOLITH_LOG, TESTCLASS_LOG, TESTCASE_LOG is set.
locations = levels_dict[level] & LogStyles.ALL_FILE_LOGS
valid_locations = [
LogStyles.TESTCASE_LOG, LogStyles.TESTCLASS_LOG,
LogStyles.MONOLITH_LOG, LogStyles.NONE]
if locations not in valid_locations:
invalid_style_error(
'More than one of MONOLITH_LOG, TESTCLASS_LOG, '
'TESTCASE_LOG is set for log level %s.' % level)
if log_style & LogStyles.ALL_LEVELS == 0:
invalid_style_error('LogStyle %s needs to set a log '
'level.' % log_style)
if log_style & ~LogStyles.ALL_LEVELS == 0:
invalid_style_error('LogStyle %s needs to set a log '
'location.' % log_style)
if log_style & LogStyles.ROTATE_LOGS and not log_style & (
LogStyles.MONOLITH_LOG | LogStyles.TESTCLASS_LOG |
LogStyles.TESTCASE_LOG):
invalid_style_error('LogStyle %s has ROTATE_LOGS set, but does '
'not specify a log type.' % log_style)
@staticmethod
def __create_rotating_file_handler(filename):
"""Generates a callable to create an appropriate RotatingFileHandler."""
# Magic number explanation: 10485760 == 10MB
return MovableRotatingFileHandler(filename, maxBytes=10485760,
backupCount=5)
@staticmethod
def __get_file_handler_creator(log_style):
"""Gets the callable to create the correct FileLogHandler."""
create_file_handler = MovableFileHandler
if log_style & LogStyles.ROTATE_LOGS:
create_file_handler = _LogStream.__create_rotating_file_handler
return create_file_handler
@staticmethod
def __get_lowest_log_level(log_style):
"""Returns the lowest log level's LogStyle for the given log_style."""
for log_level in LogStyles.LOG_LEVELS:
if log_level & log_style:
return log_level
return LogStyles.NONE
def __get_current_output_dir(self, depth=ContextLevel.TESTCASE):
"""Gets the current output directory from the context system. Make the
directory if it doesn't exist.
Args:
depth: The desired level of the output directory. For example,
the TESTCLASS level would yield the directory associated with
the current test class context, even if the test is currently
within a test case.
"""
curr_context = context.get_current_context(depth)
return curr_context.get_full_output_path(self.logger.name)
def __create_handler(self, creator, level, location):
"""Creates the FileHandler.
Args:
creator: The callable that creates the FileHandler
level: The logging level (INFO, DEBUG, etc.) for this handler.
location: The log location (MONOLITH, TESTCLASS, TESTCASE) for this
handler.
Returns: A FileHandler
"""
directory = self.__get_current_output_dir(
LogStyles.LOCATION_TO_CONTEXT_LEVEL[location])
base_name = '%s_%s.txt' % (self.name, LogStyles.LEVEL_NAMES[level])
handler = creator(os.path.join(directory, base_name))
handler.setLevel(LogStyles.LEVEL_TO_NO[level])
if self.file_format:
handler.setFormatter(self.file_format)
return handler
def __handle_style(self, log_style):
"""Creates the handlers described in the given log_style."""
handler_creator = self.__get_file_handler_creator(log_style)
# Handle streaming logs to STDOUT or the ACTS Logger
if log_style & (LogStyles.TO_ACTS_LOG | LogStyles.TO_STDOUT):
lowest_log_level = self.__get_lowest_log_level(log_style)
if log_style & LogStyles.TO_ACTS_LOG:
handler = AlsoToLogHandler()
else: # LogStyles.TO_STDOUT:
handler = StreamHandler(sys.stdout)
if self.stream_format:
handler.setFormatter(self.stream_format)
handler.setLevel(LogStyles.LEVEL_TO_NO[lowest_log_level])
self.logger.addHandler(handler)
# Handle streaming logs to log-level files
for log_level in LogStyles.LOG_LEVELS:
log_location = log_style & LogStyles.ALL_FILE_LOGS
if not (log_style & log_level and log_location):
continue
handler = self.__create_handler(
handler_creator, log_level, log_location)
self.logger.addHandler(handler)
if log_style & LogStyles.TESTCLASS_LOG:
self._testclass_handlers.append(handler)
if log_style & LogStyles.TESTCASE_LOG:
self._testcase_handlers.append(handler)
def __remove_handler(self, handler):
"""Removes a handler from the logger, unless it's a NullHandler."""
if handler is not _null_handler:
handler.close()
self.logger.removeHandler(handler)
def update_handlers(self, event):
"""Update the output file paths for log handlers upon a change in
the test context.
Args:
event: An instance of NewContextEvent.
"""
handlers = []
if isinstance(event, context.NewTestClassContextEvent):
handlers = self._testclass_handlers + self._testcase_handlers
if isinstance(event, context.NewTestCaseContextEvent):
handlers = self._testcase_handlers
if not handlers:
return
new_dir = self.__get_current_output_dir()
for handler in handlers:
filename = os.path.basename(handler.baseFilename)
handler.set_file(os.path.join(new_dir, filename))
def cleanup(self):
"""Removes all LogHandlers from the logger."""
for handler in self.logger.handlers:
self.__remove_handler(handler)