1#!/usr/bin/env python3 2# 3# Copyright 2018 - The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16import logging 17import os 18import sys 19from logging import FileHandler 20from logging import Handler 21from logging import StreamHandler 22from logging.handlers import RotatingFileHandler 23 24from acts import context 25from acts.context import ContextLevel 26from acts.event import event_bus 27from acts.event.decorators import subscribe_static 28 29 30# yapf: disable 31class LogStyles: 32 NONE = 0x00 33 LOG_DEBUG = 0x01 34 LOG_INFO = 0x02 35 LOG_WARNING = 0x04 36 LOG_ERROR = 0x08 37 LOG_CRITICAL = 0x10 38 39 DEFAULT_LEVELS = LOG_DEBUG + LOG_INFO + LOG_ERROR 40 ALL_LEVELS = LOG_DEBUG + LOG_INFO + LOG_WARNING + LOG_ERROR + LOG_CRITICAL 41 42 MONOLITH_LOG = 0x0100 43 TESTCLASS_LOG = 0x0200 44 TESTCASE_LOG = 0x0400 45 TO_STDOUT = 0x0800 46 TO_ACTS_LOG = 0x1000 47 ROTATE_LOGS = 0x2000 48 49 ALL_FILE_LOGS = MONOLITH_LOG + TESTCLASS_LOG + TESTCASE_LOG 50 51 LEVEL_NAMES = { 52 LOG_DEBUG: 'debug', 53 LOG_INFO: 'info', 54 LOG_WARNING: 'warning', 55 LOG_ERROR: 'error', 56 LOG_CRITICAL: 'critical', 57 } 58 59 LOG_LEVELS = [ 60 LOG_DEBUG, 61 LOG_INFO, 62 LOG_WARNING, 63 LOG_ERROR, 64 LOG_CRITICAL, 65 ] 66 67 LOG_LOCATIONS = [ 68 TO_STDOUT, 69 TO_ACTS_LOG, 70 MONOLITH_LOG, 71 TESTCLASS_LOG, 72 TESTCASE_LOG 73 ] 74 75 LEVEL_TO_NO = { 76 LOG_DEBUG: logging.DEBUG, 77 LOG_INFO: logging.INFO, 78 LOG_WARNING: logging.WARNING, 79 LOG_ERROR: logging.ERROR, 80 LOG_CRITICAL: logging.CRITICAL, 81 } 82 83 LOCATION_TO_CONTEXT_LEVEL = { 84 MONOLITH_LOG: ContextLevel.ROOT, 85 TESTCLASS_LOG: ContextLevel.TESTCLASS, 86 TESTCASE_LOG: ContextLevel.TESTCASE 87 } 88# yapf: enable 89 90 91_log_streams = dict() 92_null_handler = logging.NullHandler() 93 94 95@subscribe_static(context.NewContextEvent) 96def _update_handlers(event): 97 for log_stream in _log_streams.values(): 98 log_stream.update_handlers(event) 99 100 101event_bus.register_subscription(_update_handlers.subscription) 102 103 104def create_logger(name, log_name=None, base_path='', subcontext='', 105 log_styles=LogStyles.NONE, stream_format=None, 106 file_format=None): 107 """Creates a Python Logger object with the given attributes. 108 109 Creation through this method will automatically manage the logger in the 110 background for test-related events, such as TestCaseBegin and TestCaseEnd 111 Events. 112 113 Args: 114 name: The name of the LogStream. Used as the file name prefix. 115 log_name: The name of the underlying logger. Use LogStream name as 116 default. 117 base_path: The base path used by the logger. 118 subcontext: Location of logs relative to the test context path. 119 log_styles: An integer or array of integers that are the sum of 120 corresponding flag values in LogStyles. Examples include: 121 122 >>> LogStyles.LOG_INFO + LogStyles.TESTCASE_LOG 123 124 >>> LogStyles.ALL_LEVELS + LogStyles.MONOLITH_LOG 125 126 >>> [LogStyles.DEFAULT_LEVELS + LogStyles.MONOLITH_LOG] 127 >>> LogStyles.LOG_ERROR + LogStyles.TO_ACTS_LOG] 128 stream_format: Format used for log output to stream 129 file_format: Format used for log output to files 130 """ 131 if name in _log_streams: 132 _log_streams[name].cleanup() 133 log_stream = _LogStream(name, log_name, base_path, subcontext, log_styles, 134 stream_format, file_format) 135 _set_logger(log_stream) 136 return log_stream.logger 137 138 139def _set_logger(log_stream): 140 _log_streams[log_stream.name] = log_stream 141 return log_stream 142 143 144class AlsoToLogHandler(Handler): 145 """Logs a message at a given level also to another logger. 146 147 Used for logging messages at a high enough level to the main log, or another 148 logger. 149 """ 150 151 def __init__(self, to_logger=None, *args, **kwargs): 152 super().__init__(*args, **kwargs) 153 self._log = logging.getLogger(to_logger) 154 155 def emit(self, record): 156 self._log.log(record.levelno, record.getMessage()) 157 158 159class MovableFileHandler(FileHandler): 160 """FileHandler implementation that allows the output file to be changed 161 during operation. 162 """ 163 def set_file(self, file_name): 164 """Set the target output file to file_name. 165 166 Args: 167 file_name: path to the new output file 168 """ 169 self.baseFilename = os.path.abspath(file_name) 170 if self.stream is not None: 171 new_stream = self._open() 172 # An atomic operation redirects the output and closes the old file 173 os.dup2(new_stream.fileno(), self.stream.fileno()) 174 self.stream = new_stream 175 176 177class MovableRotatingFileHandler(RotatingFileHandler): 178 """RotatingFileHandler implementation that allows the output file to be 179 changed during operation. Rotated files will automatically adopt the newest 180 output path. 181 """ 182 set_file = MovableFileHandler.set_file 183 184 185class InvalidStyleSetError(Exception): 186 """Raised when the given LogStyles are an invalid set.""" 187 188 189class _LogStream(object): 190 """A class that sets up a logging.Logger object. 191 192 The LogStream class creates a logging.Logger object. LogStream is also 193 responsible for managing the logger when events take place, such as 194 TestCaseEndedEvents and TestCaseBeginEvents. 195 196 Attributes: 197 name: The name of the LogStream. 198 logger: The logger created by this LogStream. 199 base_path: The base path used by the logger. Use logging.log_path 200 as default. 201 subcontext: Location of logs relative to the test context path. 202 stream_format: Format used for log output to stream 203 file_format: Format used for log output to files 204 """ 205 206 def __init__(self, name, log_name=None, base_path='', subcontext='', 207 log_styles=LogStyles.NONE, stream_format=None, 208 file_format=None): 209 """Creates a LogStream. 210 211 Args: 212 name: The name of the LogStream. Used as the file name prefix. 213 log_name: The name of the underlying logger. Use LogStream name 214 as default. 215 base_path: The base path used by the logger. Use logging.log_path 216 as default. 217 subcontext: Location of logs relative to the test context path. 218 log_styles: An integer or array of integers that are the sum of 219 corresponding flag values in LogStyles. Examples include: 220 221 >>> LogStyles.LOG_INFO + LogStyles.TESTCASE_LOG 222 223 >>> LogStyles.ALL_LEVELS + LogStyles.MONOLITH_LOG 224 225 >>> [LogStyles.DEFAULT_LEVELS + LogStyles.MONOLITH_LOG] 226 >>> LogStyles.LOG_ERROR + LogStyles.TO_ACTS_LOG] 227 stream_format: Format used for log output to stream 228 file_format: Format used for log output to files 229 """ 230 self.name = name 231 if log_name is not None: 232 self.logger = logging.getLogger(log_name) 233 else: 234 self.logger = logging.getLogger(name) 235 # Add a NullHandler to suppress unwanted console output 236 self.logger.addHandler(_null_handler) 237 self.logger.propagate = False 238 self.base_path = base_path or getattr(logging, 'log_path', 239 '/tmp/acts_logs') 240 self.subcontext = subcontext 241 context.TestContext.add_base_output_path(self.logger.name, self.base_path) 242 context.TestContext.add_subcontext(self.logger.name, self.subcontext) 243 self.stream_format = stream_format 244 self.file_format = file_format 245 self._testclass_handlers = [] 246 self._testcase_handlers = [] 247 if not isinstance(log_styles, list): 248 log_styles = [log_styles] 249 self.__validate_styles(log_styles) 250 for log_style in log_styles: 251 self.__handle_style(log_style) 252 253 @staticmethod 254 def __validate_styles(_log_styles_list): 255 """Determines if the given list of styles is valid. 256 257 Terminology: 258 Log-level: any of [DEBUG, INFO, WARNING, ERROR, CRITICAL]. 259 Log Location: any of [MONOLITH_LOG, TESTCLASS_LOG, 260 TESTCASE_LOG, TO_STDOUT, TO_ACTS_LOG]. 261 262 Styles are invalid when any of the below criteria are met: 263 A log-level is not set within an element of the list. 264 A log location is not set within an element of the list. 265 A log-level, log location pair appears twice within the list. 266 A log-level has both TESTCLASS and TESTCASE locations set 267 within the list. 268 ROTATE_LOGS is set without MONOLITH_LOG, 269 TESTCLASS_LOG, or TESTCASE_LOG. 270 271 Raises: 272 InvalidStyleSetError if the given style cannot be achieved. 273 """ 274 275 def invalid_style_error(message): 276 raise InvalidStyleSetError('{LogStyle Set: %s} %s' % 277 (_log_styles_list, message)) 278 279 # Store the log locations that have already been set per level. 280 levels_dict = {} 281 for log_style in _log_styles_list: 282 for level in LogStyles.LOG_LEVELS: 283 if log_style & level: 284 levels_dict[level] = levels_dict.get(level, LogStyles.NONE) 285 # Check that a log-level, log location pair has not yet 286 # been set. 287 for log_location in LogStyles.LOG_LOCATIONS: 288 if log_style & log_location: 289 if log_location & levels_dict[level]: 290 invalid_style_error( 291 'The log location %s for log level %s has ' 292 'been set multiple times' % 293 (log_location, level)) 294 else: 295 levels_dict[level] |= log_location 296 # Check that for a given log-level, not more than one 297 # of MONOLITH_LOG, TESTCLASS_LOG, TESTCASE_LOG is set. 298 locations = levels_dict[level] & LogStyles.ALL_FILE_LOGS 299 valid_locations = [ 300 LogStyles.TESTCASE_LOG, LogStyles.TESTCLASS_LOG, 301 LogStyles.MONOLITH_LOG, LogStyles.NONE] 302 if locations not in valid_locations: 303 invalid_style_error( 304 'More than one of MONOLITH_LOG, TESTCLASS_LOG, ' 305 'TESTCASE_LOG is set for log level %s.' % level) 306 if log_style & LogStyles.ALL_LEVELS == 0: 307 invalid_style_error('LogStyle %s needs to set a log ' 308 'level.' % log_style) 309 if log_style & ~LogStyles.ALL_LEVELS == 0: 310 invalid_style_error('LogStyle %s needs to set a log ' 311 'location.' % log_style) 312 if log_style & LogStyles.ROTATE_LOGS and not log_style & ( 313 LogStyles.MONOLITH_LOG | LogStyles.TESTCLASS_LOG | 314 LogStyles.TESTCASE_LOG): 315 invalid_style_error('LogStyle %s has ROTATE_LOGS set, but does ' 316 'not specify a log type.' % log_style) 317 318 @staticmethod 319 def __create_rotating_file_handler(filename): 320 """Generates a callable to create an appropriate RotatingFileHandler.""" 321 # Magic number explanation: 10485760 == 10MB 322 return MovableRotatingFileHandler(filename, maxBytes=10485760, 323 backupCount=5) 324 325 @staticmethod 326 def __get_file_handler_creator(log_style): 327 """Gets the callable to create the correct FileLogHandler.""" 328 create_file_handler = MovableFileHandler 329 if log_style & LogStyles.ROTATE_LOGS: 330 create_file_handler = _LogStream.__create_rotating_file_handler 331 return create_file_handler 332 333 @staticmethod 334 def __get_lowest_log_level(log_style): 335 """Returns the lowest log level's LogStyle for the given log_style.""" 336 for log_level in LogStyles.LOG_LEVELS: 337 if log_level & log_style: 338 return log_level 339 return LogStyles.NONE 340 341 def __get_current_output_dir(self, depth=ContextLevel.TESTCASE): 342 """Gets the current output directory from the context system. Make the 343 directory if it doesn't exist. 344 345 Args: 346 depth: The desired level of the output directory. For example, 347 the TESTCLASS level would yield the directory associated with 348 the current test class context, even if the test is currently 349 within a test case. 350 """ 351 curr_context = context.get_current_context(depth) 352 return curr_context.get_full_output_path(self.logger.name) 353 354 def __create_handler(self, creator, level, location): 355 """Creates the FileHandler. 356 357 Args: 358 creator: The callable that creates the FileHandler 359 level: The logging level (INFO, DEBUG, etc.) for this handler. 360 location: The log location (MONOLITH, TESTCLASS, TESTCASE) for this 361 handler. 362 363 Returns: A FileHandler 364 """ 365 directory = self.__get_current_output_dir( 366 LogStyles.LOCATION_TO_CONTEXT_LEVEL[location]) 367 base_name = '%s_%s.txt' % (self.name, LogStyles.LEVEL_NAMES[level]) 368 handler = creator(os.path.join(directory, base_name)) 369 handler.setLevel(LogStyles.LEVEL_TO_NO[level]) 370 if self.file_format: 371 handler.setFormatter(self.file_format) 372 return handler 373 374 def __handle_style(self, log_style): 375 """Creates the handlers described in the given log_style.""" 376 handler_creator = self.__get_file_handler_creator(log_style) 377 378 # Handle streaming logs to STDOUT or the ACTS Logger 379 if log_style & (LogStyles.TO_ACTS_LOG | LogStyles.TO_STDOUT): 380 lowest_log_level = self.__get_lowest_log_level(log_style) 381 382 if log_style & LogStyles.TO_ACTS_LOG: 383 handler = AlsoToLogHandler() 384 else: # LogStyles.TO_STDOUT: 385 handler = StreamHandler(sys.stdout) 386 if self.stream_format: 387 handler.setFormatter(self.stream_format) 388 389 handler.setLevel(LogStyles.LEVEL_TO_NO[lowest_log_level]) 390 self.logger.addHandler(handler) 391 392 # Handle streaming logs to log-level files 393 for log_level in LogStyles.LOG_LEVELS: 394 log_location = log_style & LogStyles.ALL_FILE_LOGS 395 if not (log_style & log_level and log_location): 396 continue 397 398 handler = self.__create_handler( 399 handler_creator, log_level, log_location) 400 self.logger.addHandler(handler) 401 402 if log_style & LogStyles.TESTCLASS_LOG: 403 self._testclass_handlers.append(handler) 404 if log_style & LogStyles.TESTCASE_LOG: 405 self._testcase_handlers.append(handler) 406 407 def __remove_handler(self, handler): 408 """Removes a handler from the logger, unless it's a NullHandler.""" 409 if handler is not _null_handler: 410 handler.close() 411 self.logger.removeHandler(handler) 412 413 def update_handlers(self, event): 414 """Update the output file paths for log handlers upon a change in 415 the test context. 416 417 Args: 418 event: An instance of NewContextEvent. 419 """ 420 handlers = [] 421 if isinstance(event, context.NewTestClassContextEvent): 422 handlers = self._testclass_handlers + self._testcase_handlers 423 if isinstance(event, context.NewTestCaseContextEvent): 424 handlers = self._testcase_handlers 425 426 if not handlers: 427 return 428 new_dir = self.__get_current_output_dir() 429 for handler in handlers: 430 filename = os.path.basename(handler.baseFilename) 431 handler.set_file(os.path.join(new_dir, filename)) 432 433 def cleanup(self): 434 """Removes all LogHandlers from the logger.""" 435 for handler in self.logger.handlers: 436 self.__remove_handler(handler) 437