• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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        _test_case_handler_descriptors: The list of HandlerDescriptors that are
206            used to create LogHandlers for each new test case.
207        _test_case_log_handlers: The list of current LogHandlers for the current
208            test case.
209    """
210
211    def __init__(self, name, log_name=None, base_path='', subcontext='',
212                 log_styles=LogStyles.NONE, stream_format=None,
213                 file_format=None):
214        """Creates a LogStream.
215
216        Args:
217            name: The name of the LogStream. Used as the file name prefix.
218            log_name: The name of the underlying logger. Use LogStream name
219                as default.
220            base_path: The base path used by the logger. Use logging.log_path
221                as default.
222            subcontext: Location of logs relative to the test context path.
223            log_styles: An integer or array of integers that are the sum of
224                corresponding flag values in LogStyles. Examples include:
225
226                >>> LogStyles.LOG_INFO + LogStyles.TESTCASE_LOG
227
228                >>> LogStyles.ALL_LEVELS + LogStyles.MONOLITH_LOG
229
230                >>> [LogStyles.DEFAULT_LEVELS + LogStyles.MONOLITH_LOG]
231                >>>  LogStyles.LOG_ERROR + LogStyles.TO_ACTS_LOG]
232            stream_format: Format used for log output to stream
233            file_format: Format used for log output to files
234        """
235        self.name = name
236        if log_name is not None:
237            self.logger = logging.getLogger(log_name)
238        else:
239            self.logger = logging.getLogger(name)
240        # Add a NullHandler to suppress unwanted console output
241        self.logger.addHandler(_null_handler)
242        self.logger.propagate = False
243        self.base_path = base_path or logging.log_path
244        self.subcontext = subcontext
245        context.TestContext.add_base_output_path(self.logger.name, self.base_path)
246        context.TestContext.add_subcontext(self.logger.name, self.subcontext)
247        self.stream_format = stream_format
248        self.file_format = file_format
249        self._testclass_handlers = []
250        self._testcase_handlers = []
251        if not isinstance(log_styles, list):
252            log_styles = [log_styles]
253        self.__validate_styles(log_styles)
254        for log_style in log_styles:
255            self.__handle_style(log_style)
256
257    @staticmethod
258    def __validate_styles(_log_styles_list):
259        """Determines if the given list of styles is valid.
260
261        Terminology:
262            Log-level: any of [DEBUG, INFO, WARNING, ERROR, CRITICAL].
263            Log Location: any of [MONOLITH_LOG, TESTCLASS_LOG,
264                                  TESTCASE_LOG, TO_STDOUT, TO_ACTS_LOG].
265
266        Styles are invalid when any of the below criteria are met:
267            A log-level is not set within an element of the list.
268            A log location is not set within an element of the list.
269            A log-level, log location pair appears twice within the list.
270            A log-level has both TESTCLASS and TESTCASE locations set
271                within the list.
272            ROTATE_LOGS is set without MONOLITH_LOG,
273                TESTCLASS_LOG, or TESTCASE_LOG.
274
275        Raises:
276            InvalidStyleSetError if the given style cannot be achieved.
277        """
278
279        def invalid_style_error(message):
280            raise InvalidStyleSetError('{LogStyle Set: %s} %s' %
281                                       (_log_styles_list, message))
282
283        # Store the log locations that have already been set per level.
284        levels_dict = {}
285        for log_style in _log_styles_list:
286            for level in LogStyles.LOG_LEVELS:
287                if log_style & level:
288                    levels_dict[level] = levels_dict.get(level, LogStyles.NONE)
289                    # Check that a log-level, log location pair has not yet
290                    # been set.
291                    for log_location in LogStyles.LOG_LOCATIONS:
292                        if log_style & log_location:
293                            if log_location & levels_dict[level]:
294                                invalid_style_error(
295                                    'The log location %s for log level %s has '
296                                    'been set multiple times' %
297                                    (log_location, level))
298                            else:
299                                levels_dict[level] |= log_location
300                    # Check that for a given log-level, not more than one
301                    # of MONOLITH_LOG, TESTCLASS_LOG, TESTCASE_LOG is set.
302                    locations = levels_dict[level] & LogStyles.ALL_FILE_LOGS
303                    valid_locations = [
304                        LogStyles.TESTCASE_LOG, LogStyles.TESTCLASS_LOG,
305                        LogStyles.MONOLITH_LOG, LogStyles.NONE]
306                    if locations not in valid_locations:
307                        invalid_style_error(
308                            'More than one of MONOLITH_LOG, TESTCLASS_LOG, '
309                            'TESTCASE_LOG is set for log level %s.' % level)
310            if log_style & LogStyles.ALL_LEVELS == 0:
311                invalid_style_error('LogStyle %s needs to set a log '
312                                    'level.' % log_style)
313            if log_style & ~LogStyles.ALL_LEVELS == 0:
314                invalid_style_error('LogStyle %s needs to set a log '
315                                    'location.' % log_style)
316            if log_style & LogStyles.ROTATE_LOGS and not log_style & (
317                    LogStyles.MONOLITH_LOG | LogStyles.TESTCLASS_LOG |
318                    LogStyles.TESTCASE_LOG):
319                invalid_style_error('LogStyle %s has ROTATE_LOGS set, but does '
320                                    'not specify a log type.' % log_style)
321
322    @staticmethod
323    def __create_rotating_file_handler(filename):
324        """Generates a callable to create an appropriate RotatingFileHandler."""
325        # Magic number explanation: 10485760 == 10MB
326        return MovableRotatingFileHandler(filename, maxBytes=10485760,
327                                          backupCount=5)
328
329    @staticmethod
330    def __get_file_handler_creator(log_style):
331        """Gets the callable to create the correct FileLogHandler."""
332        create_file_handler = MovableFileHandler
333        if log_style & LogStyles.ROTATE_LOGS:
334            create_file_handler = _LogStream.__create_rotating_file_handler
335        return create_file_handler
336
337    @staticmethod
338    def __get_lowest_log_level(log_style):
339        """Returns the lowest log level's LogStyle for the given log_style."""
340        for log_level in LogStyles.LOG_LEVELS:
341            if log_level & log_style:
342                return log_level
343        return LogStyles.NONE
344
345    def __get_current_output_dir(self, depth=ContextLevel.TESTCASE):
346        """Gets the current output directory from the context system. Make the
347        directory if it doesn't exist.
348
349        Args:
350            depth: The desired level of the output directory. For example,
351                the TESTCLASS level would yield the directory associated with
352                the current test class context, even if the test is currently
353                within a test case.
354        """
355        curr_context = context.get_current_context(depth)
356        return curr_context.get_full_output_path(self.logger.name)
357
358    def __create_handler(self, creator, level, location):
359        """Creates the FileHandler.
360
361        Args:
362            creator: The callable that creates the FileHandler
363            level: The logging level (INFO, DEBUG, etc.) for this handler.
364            location: The log location (MONOLITH, TESTCLASS, TESTCASE) for this
365                handler.
366
367        Returns: A FileHandler
368        """
369        directory = self.__get_current_output_dir(
370            LogStyles.LOCATION_TO_CONTEXT_LEVEL[location])
371        base_name = '%s_%s.txt' % (self.name, LogStyles.LEVEL_NAMES[level])
372        handler = creator(os.path.join(directory, base_name))
373        handler.setLevel(LogStyles.LEVEL_TO_NO[level])
374        if self.file_format:
375            handler.setFormatter(self.file_format)
376        return handler
377
378    def __handle_style(self, log_style):
379        """Creates the handlers described in the given log_style."""
380        handler_creator = self.__get_file_handler_creator(log_style)
381
382        # Handle streaming logs to STDOUT or the ACTS Logger
383        if log_style & (LogStyles.TO_ACTS_LOG | LogStyles.TO_STDOUT):
384            lowest_log_level = self.__get_lowest_log_level(log_style)
385
386            if log_style & LogStyles.TO_ACTS_LOG:
387                handler = AlsoToLogHandler()
388            else:  # LogStyles.TO_STDOUT:
389                handler = StreamHandler(sys.stdout)
390                if self.stream_format:
391                    handler.setFormatter(self.stream_format)
392
393            handler.setLevel(LogStyles.LEVEL_TO_NO[lowest_log_level])
394            self.logger.addHandler(handler)
395
396        # Handle streaming logs to log-level files
397        for log_level in LogStyles.LOG_LEVELS:
398            log_location = log_style & LogStyles.ALL_FILE_LOGS
399            if not (log_style & log_level and log_location):
400                continue
401
402            handler = self.__create_handler(
403                handler_creator, log_level, log_location)
404            self.logger.addHandler(handler)
405
406            if log_style & LogStyles.TESTCLASS_LOG:
407                self._testclass_handlers.append(handler)
408            if log_style & LogStyles.TESTCASE_LOG:
409                self._testcase_handlers.append(handler)
410
411    def __remove_handler(self, handler):
412        """Removes a handler from the logger, unless it's a NullHandler."""
413        if handler is not _null_handler:
414            handler.close()
415            self.logger.removeHandler(handler)
416
417    def update_handlers(self, event):
418        """Update the output file paths for log handlers upon a change in
419        the test context.
420
421        Args:
422            event: An instance of NewContextEvent.
423        """
424        handlers = []
425        if isinstance(event, context.NewTestClassContextEvent):
426            handlers = self._testclass_handlers + self._testcase_handlers
427        if isinstance(event, context.NewTestCaseContextEvent):
428            handlers = self._testcase_handlers
429
430        if not handlers:
431            return
432        new_dir = self.__get_current_output_dir()
433        for handler in handlers:
434            filename = os.path.basename(handler.baseFilename)
435            handler.set_file(os.path.join(new_dir, filename))
436
437    def cleanup(self):
438        """Removes all LogHandlers from the logger."""
439        for handler in self.logger.handlers:
440            self.__remove_handler(handler)
441