| # Copyright 2018, The Android Open Source Project |
| # |
| # 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. |
| |
| """ |
| Utils for finder classes. |
| """ |
| |
| # pylint: disable=line-too-long |
| # pylint: disable=too-many-lines |
| |
| from __future__ import print_function |
| |
| import logging |
| import os |
| import pickle |
| import re |
| import shutil |
| import subprocess |
| import tempfile |
| import time |
| import xml.etree.ElementTree as ET |
| |
| from contextlib import contextmanager |
| from enum import unique, Enum |
| from pathlib import Path |
| from typing import Any, Dict, List, Tuple |
| |
| from atest import atest_error |
| from atest import atest_utils |
| from atest import constants |
| from atest import module_info |
| |
| from atest.atest_enum import ExitCode, DetectType |
| from atest.metrics import metrics, metrics_utils |
| from atest.test_finders import cc_test_filter_utils |
| |
| # Helps find apk files listed in a test config (AndroidTest.xml) file. |
| # Matches "filename.apk" in <option name="foo", value="filename.apk" /> |
| # We want to make sure we don't grab apks with paths in their name since we |
| # assume the apk name is the build target. |
| _APK_RE = re.compile(r'^[^/]+\.apk$', re.I) |
| |
| |
| # Group that matches java/kt method. |
| _JAVA_METHODS_RE = r'.*\s+(fun|void)\s+(?P<method>\w+)\(' |
| # Parse package name from the package declaration line of a java or |
| # a kotlin file. |
| # Group matches "foo.bar" of line "package foo.bar;" or "package foo.bar" |
| _PACKAGE_RE = re.compile(r'\s*package\s+(?P<package>[^(;|\s)]+)\s*', re.I) |
| # Matches install paths in module_info to install location(host or device). |
| _HOST_PATH_RE = re.compile(r'.*\/host\/.*', re.I) |
| _DEVICE_PATH_RE = re.compile(r'.*\/target\/.*', re.I) |
| # RE for suspected parameterized java/kt class. |
| _SUSPECTED_PARAM_CLASS_RE = re.compile( |
| r'^\s*@RunWith\s*\(\s*(TestParameterInjector|' |
| r'JUnitParamsRunner|DataProviderRunner|JukitoRunner|Theories|BedsteadJUnit4' |
| r')(\.|::)class\s*\)', re.I) |
| # RE for Java/Kt parent classes: |
| # Java: class A extends B {...} |
| # Kotlin: class A : B (...) |
| _PARENT_CLS_RE = re.compile(r'.*class\s+\w+\s+(?:extends|:)\s+' |
| r'(?P<parent>[\w\.]+)\s*(?:\{|\()') |
| _CC_GREP_RE = r'^\s*(TYPED_TEST(_P)*|TEST(_F|_P)*)\s*\({1},' |
| |
| @unique |
| class TestReferenceType(Enum): |
| """An Enum class that stores the ways of finding a reference.""" |
| # Name of a java/kotlin class, usually file is named the same |
| # (HostTest lives in HostTest.java or HostTest.kt) |
| CLASS = ( |
| constants.CLASS_INDEX, |
| r"find {0} -type f| egrep '.*/{1}\.(kt|java)$' || true") |
| # Like CLASS but also contains the package in front like |
| # com.android.tradefed.testtype.HostTest. |
| QUALIFIED_CLASS = ( |
| constants.QCLASS_INDEX, |
| r"find {0} -type f | egrep '.*{1}\.(kt|java)$' || true") |
| # Name of a Java package. |
| PACKAGE = ( |
| constants.PACKAGE_INDEX, |
| r"find {0} -wholename '*{1}' -type d -print") |
| # XML file name in one of the 4 integration config directories. |
| INTEGRATION = ( |
| constants.INT_INDEX, |
| r"find {0} -wholename '*/{1}\.xml' -print") |
| # Name of a cc/cpp class. |
| CC_CLASS = ( |
| constants.CC_CLASS_INDEX, |
| (r"find {0} -type f -print | egrep -i '/*test.*\.(cc|cpp)$'" |
| f"| xargs -P0 egrep -sH '{_CC_GREP_RE}' || true")) |
| |
| def __init__(self, index_file, find_command): |
| self.index_file = index_file |
| self.find_command = find_command |
| |
| # XML parsing related constants. |
| _COMPATIBILITY_PACKAGE_PREFIX = "com.android.compatibility" |
| _XML_PUSH_DELIM = '->' |
| _APK_SUFFIX = '.apk' |
| DALVIK_TEST_RUNNER_CLASS = 'com.android.compatibility.testtype.DalvikTest' |
| LIBCORE_TEST_RUNNER_CLASS = 'com.android.compatibility.testtype.LibcoreTest' |
| DALVIK_TESTRUNNER_JAR_CLASSES = [DALVIK_TEST_RUNNER_CLASS, |
| LIBCORE_TEST_RUNNER_CLASS] |
| DALVIK_DEVICE_RUNNER_JAR = 'cts-dalvik-device-test-runner' |
| DALVIK_HOST_RUNNER_JAR = 'cts-dalvik-host-test-runner' |
| DALVIK_TEST_DEPS = {DALVIK_DEVICE_RUNNER_JAR, |
| DALVIK_HOST_RUNNER_JAR, |
| constants.CTS_JAR} |
| # Setup script for device perf tests. |
| _PERF_SETUP_LABEL = 'perf-setup.sh' |
| _PERF_SETUP_TARGET = 'perf-setup' |
| |
| # XML tags. |
| _XML_NAME = 'name' |
| _XML_VALUE = 'value' |
| |
| # VTS xml parsing constants. |
| _VTS_TEST_MODULE = 'test-module-name' |
| _VTS_MODULE = 'module-name' |
| _VTS_BINARY_SRC = 'binary-test-source' |
| _VTS_PUSH_GROUP = 'push-group' |
| _VTS_PUSH = 'push' |
| _VTS_BINARY_SRC_DELIM = '::' |
| _VTS_PUSH_DIR = os.path.join(os.environ.get(constants.ANDROID_BUILD_TOP, ''), |
| 'test', 'vts', 'tools', 'vts-tradefed', 'res', |
| 'push_groups') |
| _VTS_PUSH_SUFFIX = '.push' |
| _VTS_BITNESS = 'append-bitness' |
| _VTS_BITNESS_TRUE = 'true' |
| _VTS_BITNESS_32 = '32' |
| _VTS_BITNESS_64 = '64' |
| _VTS_TEST_FILE = 'test-file-name' |
| _VTS_APK = 'apk' |
| # Matches 'DATA/target' in '_32bit::DATA/target' |
| _VTS_BINARY_SRC_DELIM_RE = re.compile(r'.*::(?P<target>.*)$') |
| _VTS_OUT_DATA_APP_PATH = 'DATA/app' |
| |
| def split_methods(user_input): |
| """Split user input string into test reference and list of methods. |
| |
| Args: |
| user_input: A string of the user's input. |
| Examples: |
| class_name |
| class_name#method1,method2 |
| path |
| path#method1,method2 |
| Returns: |
| A tuple. First element is String of test ref and second element is |
| a set of method name strings or empty list if no methods included. |
| Exception: |
| atest_error.TooManyMethodsError raised when input string is trying to |
| specify too many methods in a single positional argument. |
| |
| Examples of unsupported input strings: |
| module:class#method,class#method |
| class1#method,class2#method |
| path1#method,path2#method |
| """ |
| error_msg = ( |
| 'Too many "{}" characters in user input:\n\t{}\n' |
| 'Multiple classes should be separated by space, and methods belong to ' |
| 'the same class should be separated by comma. Example syntaxes are:\n' |
| '\tclass1 class2#method1 class3#method2,method3\n' |
| '\tclass1#method class2#method') |
| if not '#' in user_input: |
| if ',' in user_input: |
| raise atest_error.MoreThanOneClassError( |
| error_msg.format(',', user_input)) |
| return user_input, frozenset() |
| parts = user_input.split('#') |
| if len(parts) > 2: |
| raise atest_error.TooManyMethodsError( |
| error_msg.format('#', user_input)) |
| # (b/260183137) Support parsing multiple parameters. |
| parsed_methods = [] |
| brackets = ('[', ']') |
| for part in parts[1].split(','): |
| count = {part.count(p) for p in brackets} |
| # If brackets are in pair, the length of count should be 1. |
| if len(count) == 1: |
| parsed_methods.append(part) |
| else: |
| # The front part of the pair, e.g. 'method[1' |
| if re.compile(r'^[a-zA-Z0-9]+\[').match(part): |
| parsed_methods.append(part) |
| continue |
| # The rear part of the pair, e.g. '5]]', accumulate this part to |
| # the last index of parsed_method. |
| parsed_methods[-1] += f',{part}' |
| return parts[0], frozenset(parsed_methods) |
| |
| |
| # pylint: disable=inconsistent-return-statements |
| def get_fully_qualified_class_name(test_path): |
| """Parse the fully qualified name from the class java file. |
| |
| Args: |
| test_path: A string of absolute path to the java class file. |
| |
| Returns: |
| A string of the fully qualified class name. |
| |
| Raises: |
| atest_error.MissingPackageName if no class name can be found. |
| """ |
| with open(test_path) as class_file: |
| for line in class_file: |
| match = _PACKAGE_RE.match(line) |
| if match: |
| package = match.group('package') |
| cls = os.path.splitext(os.path.split(test_path)[1])[0] |
| return '%s.%s' % (package, cls) |
| raise atest_error.MissingPackageNameError('%s: Test class java file' |
| 'does not contain a package' |
| 'name.'% test_path) |
| |
| |
| def has_cc_class(test_path): |
| """Find out if there is any test case in the cc file. |
| |
| Args: |
| test_path: A string of absolute path to the cc file. |
| |
| Returns: |
| Boolean: has cc class in test_path or not. |
| """ |
| with open_cc(test_path) as class_file: |
| content = class_file.read() |
| if re.findall(cc_test_filter_utils.CC_CLASS_METHOD_RE, content): |
| return True |
| if re.findall(cc_test_filter_utils.CC_PARAM_CLASS_RE, content): |
| return True |
| if re.findall(cc_test_filter_utils.TYPE_CC_CLASS_RE, content): |
| return True |
| return False |
| |
| |
| def get_package_name(file_name): |
| """Parse the package name from a java file. |
| |
| Args: |
| file_name: A string of the absolute path to the java file. |
| |
| Returns: |
| A string of the package name or None |
| """ |
| with open(file_name) as data: |
| for line in data: |
| match = _PACKAGE_RE.match(line) |
| if match: |
| return match.group('package') |
| |
| |
| def get_parent_cls_name(file_name): |
| """Parse the parent class name from a java/kt file. |
| |
| Args: |
| file_name: A string of the absolute path to the javai/kt file. |
| |
| Returns: |
| A string of the parent class name or None |
| """ |
| with open(file_name) as data: |
| for line in data: |
| match = _PARENT_CLS_RE.match(line) |
| if match: |
| return match.group('parent') |
| |
| |
| def get_java_parent_paths(test_path): |
| """Find out the paths of parent classes, including itself. |
| |
| Args: |
| test_path: A string of absolute path to the test file. |
| |
| Returns: |
| A set of test paths. |
| """ |
| all_parent_test_paths = set([test_path]) |
| parent = get_parent_cls_name(test_path) |
| if not parent: |
| return all_parent_test_paths |
| # Remove <Generics> if any. |
| parent_cls = re.sub(r'\<\w+\>', '', parent) |
| package = get_package_name(test_path) |
| # Use Fully Qualified Class Name for searching precisely. |
| # package org.gnome; |
| # public class Foo extends com.android.Boo -> com.android.Boo |
| # public class Foo extends Boo -> org.gnome.Boo |
| if '.' in parent_cls: |
| parent_fqcn = parent_cls |
| else: |
| parent_fqcn = package + '.' + parent_cls |
| parent_test_paths = run_find_cmd( |
| TestReferenceType.QUALIFIED_CLASS, |
| os.environ.get(constants.ANDROID_BUILD_TOP), |
| parent_fqcn) |
| # Recursively search parent classes until the class is not found. |
| if parent_test_paths: |
| for parent_test_path in parent_test_paths: |
| all_parent_test_paths |= get_java_parent_paths(parent_test_path) |
| return all_parent_test_paths |
| |
| |
| def has_method_in_file(test_path, methods): |
| """Find out if every method can be found in the file. |
| |
| Note: This method doesn't handle if method is in comment sections. |
| |
| Args: |
| test_path: A string of absolute path to the test file. |
| methods: A set of method names. |
| |
| Returns: |
| Boolean: there is at least one method in test_path. |
| """ |
| if not os.path.isfile(test_path): |
| return False |
| all_methods = set() |
| if constants.JAVA_EXT_RE.match(test_path): |
| # omit parameterized pattern: method[0] |
| _methods = set(re.sub(r'\[\S+\]', '', x) for x in methods) |
| # Return True when every method is in the same Java file. |
| if _methods.issubset(get_java_methods(test_path)): |
| return True |
| # Otherwise, search itself and all the parent classes respectively |
| # to get all test names. |
| parent_test_paths = get_java_parent_paths(test_path) |
| logging.debug('Will search methods %s in %s\n', |
| _methods, parent_test_paths) |
| for path in parent_test_paths: |
| all_methods |= get_java_methods(path) |
| if _methods.issubset(all_methods): |
| return True |
| # If cannot find all methods, override the test_path for debugging. |
| test_path = parent_test_paths |
| elif constants.CC_EXT_RE.match(test_path): |
| # omit parameterized pattern: method/argument |
| _methods = set(re.sub(r'\/.*', '', x) for x in methods) |
| class_info = get_cc_class_info(test_path) |
| for info in class_info.values(): |
| all_methods |= info.get('methods') |
| if _methods.issubset(all_methods): |
| return True |
| missing_methods = _methods - all_methods |
| logging.debug('Cannot find methods %s in %s', |
| atest_utils.colorize(','.join(missing_methods), constants.RED), |
| test_path) |
| return False |
| |
| |
| def extract_test_path(output, methods=None): |
| """Extract the test path from the output of a unix 'find' command. |
| |
| Example of find output for CLASS find cmd: |
| /<some_root>/cts/tests/jank/src/android/jank/cts/ui/CtsDeviceJankUi.java |
| |
| Args: |
| output: A string or list output of a unix 'find' command. |
| methods: A set of method names. |
| |
| Returns: |
| A list of the test paths or None if output is '' or None. |
| """ |
| if not output: |
| return None |
| verified_tests = set() |
| if isinstance(output, str): |
| output = output.splitlines() |
| for test in output: |
| match_obj = constants.CC_OUTPUT_RE.match(test) |
| # Legacy "find" cc output (with TEST_P() syntax): |
| if match_obj: |
| fpath = match_obj.group('file_path') |
| if not methods or match_obj.group('method_name') in methods: |
| verified_tests.add(fpath) |
| # "locate" output path for both java/cc. |
| elif not methods or has_method_in_file(test, methods): |
| verified_tests.add(test) |
| return extract_test_from_tests(sorted(list(verified_tests))) |
| |
| |
| def extract_test_from_tests(tests, default_all=False): |
| """Extract the test path from the tests. |
| |
| Return the test to run from tests. If more than one option, prompt the user |
| to select multiple ones. Supporting formats: |
| - An integer. E.g. 0 |
| - Comma-separated integers. E.g. 1,3,5 |
| - A range of integers denoted by the starting integer separated from |
| the end integer by a dash, '-'. E.g. 1-3 |
| |
| Args: |
| tests: A string list which contains multiple test paths. |
| |
| Returns: |
| A string list of paths. |
| """ |
| count = len(tests) |
| if default_all or count <= 1: |
| return tests if count else None |
| mtests = set() |
| try: |
| numbered_list = ['%s: %s' % (i, t) for i, t in enumerate(tests)] |
| numbered_list.append('%s: All' % count) |
| start_prompt = time.time() |
| print('Multiple tests found:\n{0}'.format('\n'.join(numbered_list))) |
| test_indices = input("Please enter numbers of test to use. If none of " |
| "above option matched, keep searching for other " |
| "possible tests.\n(multiple selection is supported, " |
| "e.g. '1' or '0,1' or '0-2'): ") |
| for idx in re.sub(r'(\s)', '', test_indices).split(','): |
| indices = idx.split('-') |
| len_indices = len(indices) |
| if len_indices > 0: |
| start_index = min(int(indices[0]), int(indices[len_indices-1])) |
| end_index = max(int(indices[0]), int(indices[len_indices-1])) |
| # One of input is 'All', return all options. |
| if count in (start_index, end_index): |
| metrics.LocalDetectEvent( |
| detect_type=DetectType.INTERACTIVE_SELECTION, |
| result=int(time.time() - start_prompt)) |
| return tests |
| mtests.update(tests[start_index:(end_index+1)]) |
| metrics.LocalDetectEvent( |
| detect_type=DetectType.INTERACTIVE_SELECTION, |
| result=int(time.time() - start_prompt)) |
| except (ValueError, IndexError, AttributeError, TypeError) as err: |
| logging.debug('%s', err) |
| print('None of above option matched, keep searching for other' |
| ' possible tests...') |
| return list(mtests) |
| |
| |
| def run_find_cmd(ref_type, search_dir, target, methods=None): |
| """Find a path to a target given a search dir and a target name. |
| |
| Args: |
| ref_type: An Enum of the reference type. |
| search_dir: A string of the dirpath to search in. |
| target: A string of what you're trying to find. |
| methods: A set of method names. |
| |
| Return: |
| A list of the path to the target. |
| If the search_dir is inexistent, None will be returned. |
| """ |
| if not os.path.isdir(search_dir): |
| logging.debug('\'%s\' does not exist!', search_dir) |
| return None |
| ref_name = ref_type.name |
| index_file = ref_type.index_file |
| start = time.time() |
| if os.path.isfile(index_file): |
| _dict, out = {}, None |
| with open(index_file, 'rb') as index: |
| try: |
| _dict = pickle.load(index, encoding='utf-8') |
| except (UnicodeDecodeError, TypeError, IOError, EOFError, |
| AttributeError, pickle.UnpicklingError) as err: |
| logging.debug('Error occurs while loading %s: %s', index_file, err) |
| metrics_utils.handle_exc_and_send_exit_event( |
| constants.ACCESS_CACHE_FAILURE) |
| os.remove(index_file) |
| if _dict.get(target): |
| out = [path for path in _dict.get(target) if search_dir in path] |
| logging.debug('Found %s in %s', target, out) |
| else: |
| if '.' in target: |
| target = target.replace('.', '/') |
| find_cmd = ref_type.find_command.format(search_dir, target) |
| logging.debug('Executing %s find cmd: %s', ref_name, find_cmd) |
| out = subprocess.check_output(find_cmd, shell=True) |
| if isinstance(out, bytes): |
| out = out.decode() |
| logging.debug('%s find cmd out: %s', ref_name, out) |
| logging.debug('%s find completed in %ss', ref_name, time.time() - start) |
| return extract_test_path(out, methods) |
| |
| |
| def find_class_file(search_dir, class_name, is_native_test=False, methods=None): |
| """Find a path to a class file given a search dir and a class name. |
| |
| Args: |
| search_dir: A string of the dirpath to search in. |
| class_name: A string of the class to search for. |
| is_native_test: A boolean variable of whether to search for a native |
| test or not. |
| methods: A set of method names. |
| |
| Return: |
| A list of the path to the java/cc file. |
| """ |
| if is_native_test: |
| ref_type = TestReferenceType.CC_CLASS |
| elif '.' in class_name: |
| ref_type = TestReferenceType.QUALIFIED_CLASS |
| else: |
| ref_type = TestReferenceType.CLASS |
| return run_find_cmd(ref_type, search_dir, class_name, methods) |
| |
| |
| def is_equal_or_sub_dir(sub_dir, parent_dir): |
| """Return True sub_dir is sub dir or equal to parent_dir. |
| |
| Args: |
| sub_dir: A string of the sub directory path. |
| parent_dir: A string of the parent directory path. |
| |
| Returns: |
| A boolean of whether both are dirs and sub_dir is sub of parent_dir |
| or is equal to parent_dir. |
| """ |
| # avoid symlink issues with real path |
| parent_dir = os.path.realpath(parent_dir) |
| sub_dir = os.path.realpath(sub_dir) |
| if not os.path.isdir(sub_dir) or not os.path.isdir(parent_dir): |
| return False |
| return os.path.commonprefix([sub_dir, parent_dir]) == parent_dir |
| |
| |
| def find_parent_module_dir(root_dir, start_dir, module_info): |
| """From current dir search up file tree until root dir for module dir. |
| |
| Args: |
| root_dir: A string of the dir that is the parent of the start dir. |
| start_dir: A string of the dir to start searching up from. |
| module_info: ModuleInfo object containing module information from the |
| build system. |
| |
| Returns: |
| A string of the module dir relative to root, None if no Module Dir |
| found. There may be multiple testable modules at this level. |
| |
| Exceptions: |
| ValueError: Raised if cur_dir not dir or not subdir of root dir. |
| """ |
| if not is_equal_or_sub_dir(start_dir, root_dir): |
| raise ValueError('%s not in repo %s' % (start_dir, root_dir)) |
| auto_gen_dir = None |
| current_dir = start_dir |
| while current_dir != root_dir: |
| # TODO (b/112904944) - migrate module_finder functions to here and |
| # reuse them. |
| rel_dir = os.path.relpath(current_dir, root_dir) |
| # Check if actual config file here but need to make sure that there |
| # exist module in module-info with the parent dir. |
| if (os.path.isfile(os.path.join(current_dir, constants.MODULE_CONFIG)) |
| and module_info.get_module_names(current_dir)): |
| return rel_dir |
| # Check module_info if auto_gen config or robo (non-config) here |
| for mod in module_info.path_to_module_info.get(rel_dir, []): |
| if module_info.is_legacy_robolectric_class(mod): |
| return rel_dir |
| for test_config in mod.get(constants.MODULE_TEST_CONFIG, []): |
| # If the test config doesn's exist until it was auto-generated |
| # in the build time(under <android_root>/out), atest still |
| # recognizes it testable. |
| if test_config: |
| return rel_dir |
| if mod.get('auto_test_config'): |
| auto_gen_dir = rel_dir |
| # Don't return for auto_gen, keep checking for real config, |
| # because common in cts for class in apk that's in hostside |
| # test setup. |
| current_dir = os.path.dirname(current_dir) |
| return auto_gen_dir |
| |
| |
| def get_targets_from_xml(xml_file, module_info): |
| """Retrieve build targets from the given xml. |
| |
| Just a helper func on top of get_targets_from_xml_root. |
| |
| Args: |
| xml_file: abs path to xml file. |
| module_info: ModuleInfo class used to verify targets are valid modules. |
| |
| Returns: |
| A set of build targets based on the signals found in the xml file. |
| """ |
| if not os.path.isfile(xml_file): |
| return set() |
| xml_root = ET.parse(xml_file).getroot() |
| return get_targets_from_xml_root(xml_root, module_info) |
| |
| |
| def _get_apk_target(apk_target): |
| """Return the sanitized apk_target string from the xml. |
| |
| The apk_target string can be of 2 forms: |
| - apk_target.apk |
| - apk_target.apk->/path/to/install/apk_target.apk |
| |
| We want to return apk_target in both cases. |
| |
| Args: |
| apk_target: String of target name to clean. |
| |
| Returns: |
| String of apk_target to build. |
| """ |
| apk = apk_target.split(_XML_PUSH_DELIM, 1)[0].strip() |
| return apk[:-len(_APK_SUFFIX)] |
| |
| |
| def _is_apk_target(name, value): |
| """Return True if XML option is an apk target. |
| |
| We have some scenarios where an XML option can be an apk target: |
| - value is an apk file. |
| - name is a 'push' option where value holds the apk_file + other stuff. |
| |
| Args: |
| name: String name of XML option. |
| value: String value of the XML option. |
| |
| Returns: |
| True if it's an apk target we should build, False otherwise. |
| """ |
| if _APK_RE.match(value): |
| return True |
| if name == 'push' and value.endswith(_APK_SUFFIX): |
| return True |
| return False |
| |
| |
| def get_targets_from_xml_root(xml_root, module_info): |
| """Retrieve build targets from the given xml root. |
| |
| We're going to pull the following bits of info: |
| - Parse any .apk files listed in the config file. |
| - Parse option value for "test-module-name" (for vts10 tests). |
| - Look for the perf script. |
| |
| Args: |
| module_info: ModuleInfo class used to verify targets are valid modules. |
| xml_root: ElementTree xml_root for us to look through. |
| |
| Returns: |
| A set of build targets based on the signals found in the xml file. |
| """ |
| targets = set() |
| option_tags = xml_root.findall('.//option') |
| for tag in option_tags: |
| target_to_add = None |
| name = tag.attrib.get(_XML_NAME, '').strip() |
| value = tag.attrib.get(_XML_VALUE, '').strip() |
| if _is_apk_target(name, value): |
| target_to_add = _get_apk_target(value) |
| elif _PERF_SETUP_LABEL in value: |
| target_to_add = _PERF_SETUP_TARGET |
| |
| # Let's make sure we can actually build the target. |
| if target_to_add and module_info.is_module(target_to_add): |
| targets.add(target_to_add) |
| elif target_to_add: |
| logging.debug('Build target (%s) not present in module info, ' |
| 'skipping build', target_to_add) |
| |
| # TODO (b/70813166): Remove this lookup once all runtime dependencies |
| # can be listed as a build dependencies or are in the base test harness. |
| nodes_with_class = xml_root.findall(".//*[@class]") |
| for class_attr in nodes_with_class: |
| fqcn = class_attr.attrib['class'].strip() |
| if fqcn.startswith(_COMPATIBILITY_PACKAGE_PREFIX): |
| targets.add(constants.CTS_JAR) |
| if fqcn in DALVIK_TESTRUNNER_JAR_CLASSES: |
| for dalvik_dep in DALVIK_TEST_DEPS: |
| if module_info.is_module(dalvik_dep): |
| targets.add(dalvik_dep) |
| logging.debug('Targets found in config file: %s', targets) |
| return targets |
| |
| |
| def _get_vts_push_group_targets(push_file, rel_out_dir): |
| """Retrieve vts10 push group build targets. |
| |
| A push group file is a file that list out test dependencies and other push |
| group files. Go through the push file and gather all the test deps we need. |
| |
| Args: |
| push_file: Name of the push file in the VTS |
| rel_out_dir: Abs path to the out dir to help create vts10 build targets. |
| |
| Returns: |
| Set of string which represent build targets. |
| """ |
| targets = set() |
| full_push_file_path = os.path.join(_VTS_PUSH_DIR, push_file) |
| # pylint: disable=invalid-name |
| with open(full_push_file_path) as f: |
| for line in f: |
| target = line.strip() |
| # Skip empty lines. |
| if not target: |
| continue |
| |
| # This is a push file, get the targets from it. |
| if target.endswith(_VTS_PUSH_SUFFIX): |
| targets |= _get_vts_push_group_targets(line.strip(), |
| rel_out_dir) |
| continue |
| sanitized_target = target.split(_XML_PUSH_DELIM, 1)[0].strip() |
| targets.add(os.path.join(rel_out_dir, sanitized_target)) |
| return targets |
| |
| |
| def _specified_bitness(xml_root): |
| """Check if the xml file contains the option append-bitness. |
| |
| Args: |
| xml_root: abs path to xml file. |
| |
| Returns: |
| True if xml specifies to append-bitness, False otherwise. |
| """ |
| option_tags = xml_root.findall('.//option') |
| for tag in option_tags: |
| value = tag.attrib[_XML_VALUE].strip() |
| name = tag.attrib[_XML_NAME].strip() |
| if name == _VTS_BITNESS and value == _VTS_BITNESS_TRUE: |
| return True |
| return False |
| |
| |
| def _get_vts_binary_src_target(value, rel_out_dir): |
| """Parse out the vts10 binary src target. |
| |
| The value can be in the following pattern: |
| - {_32bit,_64bit,_IPC32_32bit}::DATA/target (DATA/target) |
| - DATA/target->/data/target (DATA/target) |
| - out/host/linx-x86/bin/VtsSecuritySelinuxPolicyHostTest (the string as |
| is) |
| |
| Args: |
| value: String of the XML option value to parse. |
| rel_out_dir: String path of out dir to prepend to target when required. |
| |
| Returns: |
| String of the target to build. |
| """ |
| # We'll assume right off the bat we can use the value as is and modify it if |
| # necessary, e.g. out/host/linux-x86/bin... |
| target = value |
| # _32bit::DATA/target |
| match = _VTS_BINARY_SRC_DELIM_RE.match(value) |
| if match: |
| target = os.path.join(rel_out_dir, match.group('target')) |
| # DATA/target->/data/target |
| elif _XML_PUSH_DELIM in value: |
| target = value.split(_XML_PUSH_DELIM, 1)[0].strip() |
| target = os.path.join(rel_out_dir, target) |
| return target |
| |
| |
| def get_plans_from_vts_xml(xml_file): |
| """Get configs which are included by xml_file. |
| |
| We're looking for option(include) to get all dependency plan configs. |
| |
| Args: |
| xml_file: Absolute path to xml file. |
| |
| Returns: |
| A set of plan config paths which are depended by xml_file. |
| """ |
| if not os.path.exists(xml_file): |
| raise atest_error.XmlNotExistError('%s: The xml file does' |
| 'not exist' % xml_file) |
| plans = set() |
| xml_root = ET.parse(xml_file).getroot() |
| plans.add(xml_file) |
| option_tags = xml_root.findall('.//include') |
| if not option_tags: |
| return plans |
| # Currently, all vts10 xmls live in the same dir : |
| # https://android.googlesource.com/platform/test/vts/+/master/tools/vts-tradefed/res/config/ |
| # If the vts10 plans start using folders to organize the plans, the logic here |
| # should be changed. |
| xml_dir = os.path.dirname(xml_file) |
| for tag in option_tags: |
| name = tag.attrib[_XML_NAME].strip() |
| plans |= get_plans_from_vts_xml(os.path.join(xml_dir, name + ".xml")) |
| return plans |
| |
| |
| def get_targets_from_vts_xml(xml_file, rel_out_dir, module_info): |
| """Parse a vts10 xml for test dependencies we need to build. |
| |
| We have a separate vts10 parsing function because we make a big assumption |
| on the targets (the way they're formatted and what they represent) and we |
| also create these build targets in a very special manner as well. |
| The 6 options we're looking for are: |
| - binary-test-source |
| - push-group |
| - push |
| - test-module-name |
| - test-file-name |
| - apk |
| |
| Args: |
| module_info: ModuleInfo class used to verify targets are valid modules. |
| rel_out_dir: Abs path to the out dir to help create vts10 build targets. |
| xml_file: abs path to xml file. |
| |
| Returns: |
| A set of build targets based on the signals found in the xml file. |
| """ |
| xml_root = ET.parse(xml_file).getroot() |
| targets = set() |
| option_tags = xml_root.findall('.//option') |
| for tag in option_tags: |
| value = tag.attrib[_XML_VALUE].strip() |
| name = tag.attrib[_XML_NAME].strip() |
| if name in [_VTS_TEST_MODULE, _VTS_MODULE]: |
| if module_info.is_module(value): |
| targets.add(value) |
| else: |
| logging.debug('vts10 test module (%s) not present in module ' |
| 'info, skipping build', value) |
| elif name == _VTS_BINARY_SRC: |
| targets.add(_get_vts_binary_src_target(value, rel_out_dir)) |
| elif name == _VTS_PUSH_GROUP: |
| # Look up the push file and parse out build artifacts (as well as |
| # other push group files to parse). |
| targets |= _get_vts_push_group_targets(value, rel_out_dir) |
| elif name == _VTS_PUSH: |
| # Parse out the build artifact directly. |
| push_target = value.split(_XML_PUSH_DELIM, 1)[0].strip() |
| # If the config specified append-bitness, append the bits suffixes |
| # to the target. |
| if _specified_bitness(xml_root): |
| targets.add(os.path.join( |
| rel_out_dir, push_target + _VTS_BITNESS_32)) |
| targets.add(os.path.join( |
| rel_out_dir, push_target + _VTS_BITNESS_64)) |
| else: |
| targets.add(os.path.join(rel_out_dir, push_target)) |
| elif name == _VTS_TEST_FILE: |
| # The _VTS_TEST_FILE values can be set in 2 possible ways: |
| # 1. test_file.apk |
| # 2. DATA/app/test_file/test_file.apk |
| # We'll assume that test_file.apk (#1) is in an expected path (but |
| # that is not true, see b/76158619) and create the full path for it |
| # and then append the _VTS_TEST_FILE value to targets to build. |
| target = os.path.join(rel_out_dir, value) |
| # If value is just an APK, specify the path that we expect it to be in |
| # e.g. out/host/linux-x86/vts10/android-vts10/testcases/DATA/app/test_file/test_file.apk |
| head, _ = os.path.split(value) |
| if not head: |
| target = os.path.join(rel_out_dir, _VTS_OUT_DATA_APP_PATH, |
| _get_apk_target(value), value) |
| targets.add(target) |
| elif name == _VTS_APK: |
| targets.add(os.path.join(rel_out_dir, value)) |
| logging.debug('Targets found in config file: %s', targets) |
| return targets |
| |
| |
| def get_dir_path_and_filename(path): |
| """Return tuple of dir and file name from given path. |
| |
| Args: |
| path: String of path to break up. |
| |
| Returns: |
| Tuple of (dir, file) paths. |
| """ |
| if os.path.isfile(path): |
| dir_path, file_path = os.path.split(path) |
| else: |
| dir_path, file_path = path, None |
| return dir_path, file_path |
| |
| |
| def search_integration_dirs(name, int_dirs): |
| """Search integration dirs for name and return full path. |
| |
| Args: |
| name: A string of plan name needed to be found. |
| int_dirs: A list of path needed to be searched. |
| |
| Returns: |
| A list of the test path. |
| Ask user to select if multiple tests are found. |
| None if no matched test found. |
| """ |
| root_dir = os.environ.get(constants.ANDROID_BUILD_TOP) |
| test_files = [] |
| for integration_dir in int_dirs: |
| abs_path = os.path.join(root_dir, integration_dir) |
| test_paths = run_find_cmd(TestReferenceType.INTEGRATION, abs_path, |
| name) |
| if test_paths: |
| test_files.extend(test_paths) |
| return extract_test_from_tests(test_files) |
| |
| |
| def get_int_dir_from_path(path, int_dirs): |
| """Search integration dirs for the given path and return path of dir. |
| |
| Args: |
| path: A string of path needed to be found. |
| int_dirs: A list of path needed to be searched. |
| |
| Returns: |
| A string of the test dir. None if no matched path found. |
| """ |
| root_dir = os.environ.get(constants.ANDROID_BUILD_TOP) |
| if not os.path.exists(path): |
| return None |
| dir_path, file_name = get_dir_path_and_filename(path) |
| int_dir = None |
| for possible_dir in int_dirs: |
| abs_int_dir = os.path.join(root_dir, possible_dir) |
| if is_equal_or_sub_dir(dir_path, abs_int_dir): |
| int_dir = abs_int_dir |
| break |
| if not file_name: |
| logging.debug('Found dir (%s) matching input (%s).' |
| ' Referencing an entire Integration/Suite dir' |
| ' is not supported. If you are trying to reference' |
| ' a test by its path, please input the path to' |
| ' the integration/suite config file itself.', |
| int_dir, path) |
| return None |
| return int_dir |
| |
| |
| def get_install_locations(installed_paths): |
| """Get install locations from installed paths. |
| |
| Args: |
| installed_paths: List of installed_paths from module_info. |
| |
| Returns: |
| Set of install locations from module_info installed_paths. e.g. |
| set(['host', 'device']) |
| """ |
| install_locations = set() |
| for path in installed_paths: |
| if _HOST_PATH_RE.match(path): |
| install_locations.add(constants.DEVICELESS_TEST) |
| elif _DEVICE_PATH_RE.match(path): |
| install_locations.add(constants.DEVICE_TEST) |
| return install_locations |
| |
| |
| def get_levenshtein_distance(test_name, module_name, |
| dir_costs=constants.COST_TYPO): |
| """Return an edit distance between test_name and module_name. |
| |
| Levenshtein Distance has 3 actions: delete, insert and replace. |
| dis_costs makes each action weigh differently. |
| |
| Args: |
| test_name: A keyword from the users. |
| module_name: A testable module name. |
| dir_costs: A tuple which contains 3 integer, where dir represents |
| Deletion, Insertion and Replacement respectively. |
| For guessing typos: (1, 1, 1) gives the best result. |
| For searching keywords, (8, 1, 5) gives the best result. |
| |
| Returns: |
| An edit distance integer between test_name and module_name. |
| """ |
| rows = len(test_name) + 1 |
| cols = len(module_name) + 1 |
| deletion, insertion, replacement = dir_costs |
| |
| # Creating a Dynamic Programming Matrix and weighting accordingly. |
| dp_matrix = [[0 for _ in range(cols)] for _ in range(rows)] |
| # Weigh rows/deletion |
| for row in range(1, rows): |
| dp_matrix[row][0] = row * deletion |
| # Weigh cols/insertion |
| for col in range(1, cols): |
| dp_matrix[0][col] = col * insertion |
| # The core logic of LD |
| for col in range(1, cols): |
| for row in range(1, rows): |
| if test_name[row-1] == module_name[col-1]: |
| cost = 0 |
| else: |
| cost = replacement |
| dp_matrix[row][col] = min(dp_matrix[row-1][col] + deletion, |
| dp_matrix[row][col-1] + insertion, |
| dp_matrix[row-1][col-1] + cost) |
| |
| return dp_matrix[row][col] |
| |
| |
| def is_test_from_kernel_xml(xml_file, test_name): |
| """Check if test defined in xml_file. |
| |
| A kernel test can be defined like: |
| <option name="test-command-line" key="test_class_1" value="command 1" /> |
| where key is the name of test class and method of the runner. This method |
| returns True if the test_name was defined in the given xml_file. |
| |
| Args: |
| xml_file: Absolute path to xml file. |
| test_name: test_name want to find. |
| |
| Returns: |
| True if test_name in xml_file, False otherwise. |
| """ |
| if not os.path.exists(xml_file): |
| return False |
| xml_root = ET.parse(xml_file).getroot() |
| option_tags = xml_root.findall('.//option') |
| for option_tag in option_tags: |
| if option_tag.attrib['name'] == 'test-command-line': |
| if option_tag.attrib['key'] == test_name: |
| return True |
| return False |
| |
| |
| def is_parameterized_java_class(test_path): |
| """Find out if input test path is a parameterized java class. |
| |
| Args: |
| test_path: A string of absolute path to the java file. |
| |
| Returns: |
| Boolean: Is parameterized class or not. |
| """ |
| with open(test_path) as class_file: |
| for line in class_file: |
| # Return immediately if the @ParameterizedTest annotation is found. |
| if re.compile(r'\s*@ParameterizedTest').match(line): |
| return True |
| # Return when Parameterized.class is invoked in @RunWith annotation. |
| # @RunWith(Parameterized.class) -> Java. |
| # @RunWith(Parameterized::class) -> kotlin. |
| if re.compile( |
| r'^\s*@RunWith\s*\(\s*Parameterized.*(\.|::)class').match(line): |
| return True |
| if _SUSPECTED_PARAM_CLASS_RE.match(line): |
| return True |
| return False |
| |
| |
| def get_java_methods(test_path): |
| """Find out the java test class of input test_path. |
| |
| Args: |
| test_path: A string of absolute path to the java file. |
| |
| Returns: |
| A set of methods. |
| """ |
| logging.debug('Probing %s:', test_path) |
| with open(test_path) as class_file: |
| content = class_file.read() |
| matches = re.findall(_JAVA_METHODS_RE, content) |
| if matches: |
| methods = {match[1] for match in matches} |
| logging.debug('Available methods: %s\n', methods) |
| return methods |
| return set() |
| |
| |
| @contextmanager |
| def open_cc(filename: str): |
| """Open a cc/cpp file with comments trimmed.""" |
| target_cc = filename |
| if shutil.which('gcc'): |
| tmp = tempfile.NamedTemporaryFile() |
| cmd = f'gcc -fpreprocessed -dD -E {filename} > {tmp.name}' |
| strip_proc = subprocess.run(cmd, shell=True, check=False) |
| if strip_proc.returncode == ExitCode.SUCCESS: |
| target_cc = tmp.name |
| else: |
| logging.debug('Failed to strip comments in %s. Parsing ' |
| 'class/method name may not be accurate.', |
| target_cc) |
| else: |
| logging.debug('Cannot find "gcc" and unable to trim comments.') |
| try: |
| cc_obj = open(target_cc, 'r') |
| yield cc_obj |
| finally: |
| cc_obj.close() |
| |
| |
| # pylint: disable=too-many-branches |
| def get_cc_class_info(test_path): |
| """Get the class info of the given cc input test_path. |
| |
| The class info dict will be like: |
| {'classA': { |
| 'methods': {'m1', 'm2'}, 'prefixes': {'pfx1'}, 'typed': True}, |
| 'classB': { |
| 'methods': {'m3', 'm4'}, 'prefixes': set(), 'typed': False}, |
| 'classC': { |
| 'methods': {'m5', 'm6'}, 'prefixes': set(), 'typed': True}, |
| 'classD': { |
| 'methods': {'m7', 'm8'}, 'prefixes': {'pfx3'}, 'typed': False}} |
| According to the class info, we can tell that: |
| classA is a typed-parameterized test. (TYPED_TEST_SUITE_P) |
| classB is a regular gtest. (TEST_F|TEST) |
| classC is a typed test. (TYPED_TEST_SUITE) |
| classD is a value-parameterized test. (TEST_P) |
| |
| Args: |
| test_path: A string of absolute path to the cc file. |
| |
| Returns: |
| A dict of class info. |
| """ |
| with open_cc(test_path) as class_file: |
| content = class_file.read() |
| logging.debug('Parsing: %s', test_path) |
| class_info, no_test_classes = cc_test_filter_utils.get_cc_class_info( |
| content) |
| |
| if no_test_classes: |
| metrics.LocalDetectEvent( |
| detect_type=DetectType.NATIVE_TEST_NOT_FOUND, |
| result=DetectType.NATIVE_TEST_NOT_FOUND) |
| |
| return class_info |
| |
| |
| def find_host_unit_tests(module_info, path): |
| """Find host unit tests for the input path. |
| |
| Args: |
| module_info: ModuleInfo obj. |
| path: A string of the relative path from $ANDROID_BUILD_TOP that we want |
| to search. |
| |
| Returns: |
| A list that includes the module name of host unit tests, otherwise an empty |
| list. |
| """ |
| logging.debug('finding host unit tests under %s', path) |
| host_unit_test_names = module_info.get_all_host_unit_tests() |
| logging.debug('All the host unit tests: %s', host_unit_test_names) |
| |
| # Return all tests if the path relative to ${ANDROID_BUILD_TOP} is '.'. |
| if path == '.': |
| return host_unit_test_names |
| |
| tests = [] |
| for name in host_unit_test_names: |
| for test_path in module_info.get_paths(name): |
| if test_path.find(path) == 0: |
| tests.append(name) |
| return tests |
| |
| def get_annotated_methods(annotation, file_path): |
| """Find all the methods annotated by the input annotation in the file_path. |
| |
| Args: |
| annotation: A string of the annotation class. |
| file_path: A string of the file path. |
| |
| Returns: |
| A set of all the methods annotated. |
| """ |
| methods = set() |
| annotation_name = '@' + str(annotation).split('.')[-1] |
| with open(file_path) as class_file: |
| enter_annotation_block = False |
| for line in class_file: |
| if str(line).strip().startswith(annotation_name): |
| enter_annotation_block = True |
| continue |
| if enter_annotation_block: |
| matches = re.findall(_JAVA_METHODS_RE, line) |
| if matches: |
| methods.update({match[1] for match in matches}) |
| enter_annotation_block = False |
| continue |
| return methods |
| |
| |
| def get_test_config_and_srcs(test_info, module_info): |
| """Get the test config path for the input test_info. |
| |
| The search rule will be: |
| Check if test name in test_info could be found in module_info |
| 1. AndroidTest.xml under module path if no test config be set. |
| 2. The first test config defined in Android.bp if test config be set. |
| If test name could not found matched module in module_info, search all the |
| test config name if match. |
| |
| Args: |
| test_info: TestInfo obj. |
| module_info: ModuleInfo obj. |
| |
| Returns: |
| A string of the config path and list of srcs, None if test config not |
| exist. |
| """ |
| test_name = test_info.test_name |
| mod_info = module_info.get_module_info(test_name) |
| |
| if mod_info: |
| get_config_srcs_tuple = _get_config_srcs_tuple_from_module_info |
| ref_obj = mod_info |
| else: |
| # For tests that the configs were generated by soong and the test_name |
| # cannot be found in module_info. |
| get_config_srcs_tuple = _get_config_srcs_tuple_when_no_module_info |
| ref_obj = module_info |
| |
| config_src_tuple = get_config_srcs_tuple(ref_obj, test_name) |
| return config_src_tuple if config_src_tuple else (None, None) |
| |
| |
| def _get_config_srcs_tuple_from_module_info( |
| mod_info: Dict[str, Any], |
| _=None) -> Tuple[str, List[str]]: |
| """Get test config and srcs from the given info of the module.""" |
| android_root_dir = os.environ.get(constants.ANDROID_BUILD_TOP) |
| test_configs = mod_info.get(constants.MODULE_TEST_CONFIG, []) |
| if len(test_configs) == 0: |
| # Check for AndroidTest.xml at the module path. |
| for path in mod_info.get(constants.MODULE_PATH, []): |
| config_path = os.path.join( |
| android_root_dir, path, constants.MODULE_CONFIG) |
| if os.path.isfile(config_path): |
| return config_path, mod_info.get(constants.MODULE_SRCS, []) |
| if len(test_configs) >= 1: |
| test_config = test_configs[0] |
| config_path = os.path.join(android_root_dir, test_config) |
| if os.path.isfile(config_path): |
| return config_path, mod_info.get(constants.MODULE_SRCS, []) |
| return None, None |
| |
| |
| def _get_config_srcs_tuple_when_no_module_info( |
| module_info_obj: module_info.ModuleInfo, |
| test_name: str) -> Tuple[Path, List[str]]: |
| """Get test config and srcs by iterating the whole module_info.""" |
| def get_config_srcs(info: Dict[str, Any], test_name: str): |
| test_configs = info.get(constants.MODULE_TEST_CONFIG, []) |
| for test_config in test_configs: |
| config_path = atest_utils.get_build_top(test_config) |
| config_name = config_path.stem |
| if config_name == test_name and os.path.isfile(config_path): |
| return config_path, info.get(constants.MODULE_SRCS, []) |
| return None, None |
| |
| infos = (module_info_obj.get_module_info(mod) |
| for mod in module_info_obj.get_testable_modules()) |
| |
| for info in infos: |
| results = get_config_srcs(info, test_name) |
| if any(results): |
| return results |
| return None, None |
| |
| |
| def need_aggregate_metrics_result(test_xml: str) -> bool: |
| """Check if input test config need aggregate metrics. |
| |
| If the input test define metrics_collector, which means there's a need for |
| atest to have the aggregate metrics result. |
| |
| Args: |
| test_xml: A string of the path for the test xml. |
| |
| Returns: |
| True if input test need to enable aggregate metrics result. |
| """ |
| # Due to (b/211640060) it may replace .xml with .config in the xml as |
| # workaround. |
| if not Path(test_xml).is_file(): |
| if Path(test_xml).suffix == '.config': |
| test_xml = test_xml.rsplit('.', 1)[0] + '.xml' |
| |
| if Path(test_xml).is_file(): |
| xml_root = ET.parse(test_xml).getroot() |
| if xml_root.findall('.//metrics_collector'): |
| return True |
| # Recursively check included configs in the same git repository. |
| git_dir = get_git_path(test_xml) |
| include_configs = xml_root.findall('.//include') |
| for include_config in include_configs: |
| name = include_config.attrib[_XML_NAME].strip() |
| # Get the absolute path for the included configs. |
| include_paths = search_integration_dirs( |
| os.path.splitext(name)[0], [git_dir]) |
| for include_path in include_paths: |
| if need_aggregate_metrics_result(include_path): |
| return True |
| return False |
| |
| |
| def get_git_path(file_path: str) -> str: |
| """Get the path of the git repository for the input file. |
| |
| Args: |
| file_path: A string of the path to find the git path it belongs. |
| |
| Returns: |
| The path of the git repository for the input file, return the path of |
| $ANDROID_BUILD_TOP if nothing find. |
| """ |
| build_top = os.environ.get(constants.ANDROID_BUILD_TOP) |
| parent = Path(file_path).absolute().parent |
| while not parent.samefile('/') and not parent.samefile(build_top): |
| if parent.joinpath('.git').is_dir(): |
| return parent.absolute() |
| parent = parent.parent |
| return build_top |
| |
| |
| def parse_test_reference(test_ref: str) -> Dict[str, str]: |
| """Parse module, class/pkg, and method name from the given test reference. |
| |
| The result will be a none empty dictionary only if input test reference |
| match $module:$pkg_class or $module:$pkg_class:$method. |
| |
| Args: |
| test_ref: A string of the input test reference from command line. |
| |
| Returns: |
| Dict includes module_name, pkg_class_name and method_name. |
| """ |
| ref_match = re.match( |
| r'^(?P<module_name>[^:#]+):(?P<pkg_class_name>[^#]+)' |
| r'#?(?P<method_name>.*)$', test_ref) |
| |
| return ref_match.groupdict(default=dict()) if ref_match else dict() |