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