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.
16
17import enum
18import logging
19import os
20
21
22class ContextLevel(enum.IntEnum):
23    ROOT = 0
24    TESTCLASS = 1
25    TESTCASE = 2
26
27
28def get_current_context(depth=None):
29    """Get the current test context at the specified depth.
30    Pulls the most recently created context, with a level at or below the given
31    depth, from the _contexts stack.
32
33    Args:
34        depth: The desired context level. For example, the TESTCLASS level would
35            yield the current test class context, even if the test is currently
36            within a test case.
37
38    Returns: An instance of TestContext.
39    """
40    if depth is None:
41        return _contexts[-1]
42    return _contexts[min(depth, len(_contexts) - 1)]
43
44
45def append_test_context(test_class_name, test_name):
46    """Add test-specific context to the _contexts stack.
47    A test should should call append_test_context() at test start and
48    pop_test_context() upon test end.
49
50    Args:
51        test_class_name: name of the test class.
52        test_name: name of the test.
53    """
54    if _contexts:
55        _contexts.append(TestCaseContext(test_class_name, test_name))
56
57
58def pop_test_context():
59    """Remove the latest test-specific context from the _contexts stack.
60    A test should should call append_test_context() at test start and
61    pop_test_context() upon test end.
62    """
63    if _contexts:
64        _contexts.pop()
65
66
67class TestContext(object):
68    """An object representing the current context in which a test is executing.
69
70    The context encodes the current state of the test runner with respect to a
71    particular scenario in which code is being executed. For example, if some
72    code is being executed as part of a test case, then the context should
73    encode information about that test case such as its name or enclosing
74    class.
75
76    The subcontext specifies a relative path in which certain outputs,
77    e.g. logcat, should be kept for the given context.
78
79    The full output path is given by
80    <base_output_path>/<context_dir>/<subcontext>.
81
82    Attributes:
83        _base_output_paths: a dictionary mapping a logger's name to its base
84                            output path
85        _subcontexts: a dictionary mapping a logger's name to its
86                      subcontext-level output directory
87    """
88
89    _base_output_paths = {}
90    _subcontexts = {}
91
92    def get_base_output_path(self, log_name=None):
93        """Gets the base output path for this logger.
94
95        The base output path is interpreted as the reporting root for the
96        entire test runner.
97
98        If a path has been added with add_base_output_path, it is returned.
99        Otherwise, a default is determined by _get_default_base_output_path().
100
101        Args:
102            log_name: The name of the logger.
103
104        Returns:
105            The output path.
106        """
107        if log_name in self._base_output_paths:
108            return self._base_output_paths[log_name]
109        return self._get_default_base_output_path()
110
111    def get_subcontext(self, log_name=None):
112        """Gets the subcontext for this logger.
113
114        The subcontext is interpreted as the directory, relative to the
115        context-level path, where all outputs of the given logger are stored.
116
117        If a path has been added with add_subcontext, it is returned.
118        Otherwise, the empty string is returned.
119
120        Args:
121            log_name: The name of the logger.
122
123        Returns:
124            The output path.
125        """
126        return self._subcontexts.get(log_name, '')
127
128    def get_full_output_path(self, log_name=None):
129        """Gets the full output path for this context.
130
131        The full path represents the absolute path to the output directory,
132        as given by <base_output_path>/<context_dir>/<subcontext>
133
134        Args:
135            log_name: The name of the logger. Used to specify the base output
136                      path and the subcontext.
137
138        Returns:
139            The output path.
140        """
141
142        path = os.path.join(
143            self.get_base_output_path(log_name), self._get_default_context_dir(), self.get_subcontext(log_name))
144        os.makedirs(path, exist_ok=True)
145        return path
146
147    def _get_default_base_output_path(self):
148        """Gets the default base output path.
149
150        This will attempt to use logging path set up in the global
151        logger.
152
153        Returns:
154            The logging path.
155
156        Raises:
157            EnvironmentError: If logger has not been initialized.
158        """
159        try:
160            return logging.log_path
161        except AttributeError as e:
162            raise EnvironmentError('The Mobly logger has not been set up and'
163                                   ' "base_output_path" has not been set.') from e
164
165    def _get_default_context_dir(self):
166        """Gets the default output directory for this context."""
167        raise NotImplementedError()
168
169
170class RootContext(TestContext):
171    """A TestContext that represents a test run."""
172
173    @property
174    def identifier(self):
175        return 'root'
176
177    def _get_default_context_dir(self):
178        """Gets the default output directory for this context.
179
180        Logs at the root level context are placed directly in the base level
181        directory, so no context-level path exists."""
182        return ''
183
184
185class TestCaseContext(TestContext):
186    """A TestContext that represents a test case.
187
188    Attributes:
189        test_case: the name of the test case.
190        test_class: the name of the test class.
191    """
192
193    def __init__(self, test_class, test_case):
194        """Initializes a TestCaseContext for the given test case.
195
196        Args:
197            test_class: test-class name.
198            test_case: test name.
199        """
200        self.test_class = test_class
201        self.test_case = test_case
202
203    @property
204    def test_case_name(self):
205        return self.test_case
206
207    @property
208    def test_class_name(self):
209        return self.test_class
210
211    @property
212    def identifier(self):
213        return '%s.%s' % (self.test_class_name, self.test_case_name)
214
215    def _get_default_context_dir(self):
216        """Gets the default output directory for this context.
217
218        For TestCaseContexts, this will be the name of the test itself.
219        """
220        return self.test_case_name
221
222
223# stack for keeping track of the current test context
224_contexts = [RootContext()]
225