| 1 | # TestFinder class, define set of tests to run. |
| 2 | # |
| 3 | # Copyright (c) 2020-2021 Virtuozzo International GmbH |
| 4 | # |
| 5 | # This program is free software; you can redistribute it and/or modify |
| 6 | # it under the terms of the GNU General Public License as published by |
| 7 | # the Free Software Foundation; either version 2 of the License, or |
| 8 | # (at your option) any later version. |
| 9 | # |
| 10 | # This program is distributed in the hope that it will be useful, |
| 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 | # GNU General Public License for more details. |
| 14 | # |
| 15 | # You should have received a copy of the GNU General Public License |
| 16 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 17 | # |
| 18 | |
| 19 | import os |
| 20 | import glob |
| 21 | import re |
| 22 | from collections import defaultdict |
| 23 | from contextlib import contextmanager |
| 24 | from typing import Optional, List, Iterator, Set |
| 25 | |
| 26 | |
| 27 | @contextmanager |
| 28 | def chdir(path: Optional[str] = None) -> Iterator[None]: |
| 29 | if path is None: |
| 30 | yield |
| 31 | return |
| 32 | |
| 33 | saved_dir = os.getcwd() |
| 34 | os.chdir(path) |
| 35 | try: |
| 36 | yield |
| 37 | finally: |
| 38 | os.chdir(saved_dir) |
| 39 | |
| 40 | |
| 41 | class TestFinder: |
| 42 | def __init__(self, test_dir: Optional[str] = None) -> None: |
| 43 | self.groups = defaultdict(set) |
| 44 | |
| 45 | with chdir(test_dir): |
| 46 | self.all_tests = glob.glob('[0-9][0-9][0-9]') |
| 47 | self.all_tests += [f for f in glob.iglob('tests/*') |
| 48 | if not f.endswith('.out') and |
| 49 | os.path.isfile(f + '.out')] |
| 50 | |
| 51 | for t in self.all_tests: |
| 52 | with open(t, encoding="utf-8") as f: |
| 53 | for line in f: |
| 54 | if line.startswith('# group: '): |
| 55 | for g in line.split()[2:]: |
| 56 | self.groups[g].add(t) |
| 57 | break |
| 58 | |
| 59 | def add_group_file(self, fname: str) -> None: |
| 60 | with open(fname, encoding="utf-8") as f: |
| 61 | for line in f: |
| 62 | line = line.strip() |
| 63 | |
| 64 | if (not line) or line[0] == '#': |
| 65 | continue |
| 66 | |
| 67 | words = line.split() |
| 68 | test_file = self.parse_test_name(words[0]) |
| 69 | groups = words[1:] |
| 70 | |
| 71 | for g in groups: |
| 72 | self.groups[g].add(test_file) |
| 73 | |
| 74 | def parse_test_name(self, name: str) -> str: |
| 75 | if '/' in name: |
| 76 | raise ValueError('Paths are unsupported for test selection, ' |
| 77 | f'requiring "{name}" is wrong') |
| 78 | |
| 79 | if re.fullmatch(r'\d+', name): |
| 80 | # Numbered tests are old naming convention. We should convert them |
| 81 | # to three-digit-length, like 1 --> 001. |
| 82 | name = f'{int(name):03}' |
| 83 | else: |
| 84 | # Named tests all should be in tests/ subdirectory |
| 85 | name = os.path.join('tests', name) |
| 86 | |
| 87 | if name not in self.all_tests: |
| 88 | raise ValueError(f'Test "{name}" is not found') |
| 89 | |
| 90 | return name |
| 91 | |
| 92 | def find_tests(self, groups: Optional[List[str]] = None, |
| 93 | exclude_groups: Optional[List[str]] = None, |
| 94 | tests: Optional[List[str]] = None, |
| 95 | start_from: Optional[str] = None) -> List[str]: |
| 96 | """Find tests |
| 97 | |
| 98 | Algorithm: |
| 99 | |
| 100 | 1. a. if some @groups specified |
| 101 | a.1 Take all tests from @groups |
| 102 | a.2 Drop tests, which are in at least one of @exclude_groups or in |
| 103 | 'disabled' group (if 'disabled' is not listed in @groups) |
| 104 | a.3 Add tests from @tests (don't exclude anything from them) |
| 105 | |
| 106 | b. else, if some @tests specified: |
| 107 | b.1 exclude_groups must be not specified, so just take @tests |
| 108 | |
| 109 | c. else (only @exclude_groups list is non-empty): |
| 110 | c.1 Take all tests |
| 111 | c.2 Drop tests, which are in at least one of @exclude_groups or in |
| 112 | 'disabled' group |
| 113 | |
| 114 | 2. sort |
| 115 | |
| 116 | 3. If start_from specified, drop tests from first one to @start_from |
| 117 | (not inclusive) |
| 118 | """ |
| 119 | if groups is None: |
| 120 | groups = [] |
| 121 | if exclude_groups is None: |
| 122 | exclude_groups = [] |
| 123 | if tests is None: |
| 124 | tests = [] |
| 125 | |
| 126 | res: Set[str] = set() |
| 127 | if groups: |
| 128 | # Some groups specified. exclude_groups supported, additionally |
| 129 | # selecting some individual tests supported as well. |
| 130 | res.update(*(self.groups[g] for g in groups)) |
| 131 | elif tests: |
| 132 | # Some individual tests specified, but no groups. In this case |
| 133 | # we don't support exclude_groups. |
| 134 | if exclude_groups: |
| 135 | raise ValueError("Can't exclude from individually specified " |
| 136 | "tests.") |
| 137 | else: |
| 138 | # No tests no groups: start from all tests, exclude_groups |
| 139 | # supported. |
| 140 | res.update(self.all_tests) |
| 141 | |
| 142 | if 'disabled' not in groups and 'disabled' not in exclude_groups: |
| 143 | # Don't want to modify function argument, so create new list. |
| 144 | exclude_groups = exclude_groups + ['disabled'] |
| 145 | |
| 146 | res = res.difference(*(self.groups[g] for g in exclude_groups)) |
| 147 | |
| 148 | # We want to add @tests. But for compatibility with old test names, |
| 149 | # we should convert any number < 100 to number padded by |
| 150 | # leading zeroes, like 1 -> 001 and 23 -> 023. |
| 151 | for t in tests: |
| 152 | res.add(self.parse_test_name(t)) |
| 153 | |
| 154 | sequence = sorted(res) |
| 155 | |
| 156 | if start_from is not None: |
| 157 | del sequence[:sequence.index(self.parse_test_name(start_from))] |
| 158 | |
| 159 | return sequence |