1# Copyright 2021 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Module for interacting with expectation files.""" 5 6import base64 7import collections 8from datetime import timedelta 9import itertools 10import os 11import posixpath 12import re 13from typing import Dict, List, Set, Tuple, Union 14import urllib.request 15 16from flake_suppressor_common import common_typing as ct 17 18from typ import expectations_parser 19 20CHROMIUM_SRC_DIR = os.path.realpath( 21 os.path.join(os.path.dirname(__file__), '..', '..')) 22GITILES_URL = 'https://chromium.googlesource.com/chromium/src/+/refs/heads/main' 23TEXT_FORMAT_ARG = '?format=TEXT' 24 25TAG_GROUP_REGEX = re.compile(r'# tags: \[([^\]]*)\]', re.MULTILINE | re.DOTALL) 26 27TestToUrlsType = Dict[str, List[str]] 28SuiteToTestsType = Dict[str, TestToUrlsType] 29TagOrderedAggregateResultType = Dict[ct.TagTupleType, SuiteToTestsType] 30 31 32def OverFailedBuildThreshold(failed_result_tuple_list: List[ct.ResultTupleType], 33 build_fail_total_number_threshold: int) -> bool: 34 """Check if the number of failed build in |failed_result_tuple_list| is 35 equal to or more than |build_fail_total_number_threshold|. 36 37 Args: 38 failed_result_tuple_list: A list of ct.ResultTupleType failed test results. 39 build_fail_total_number_threshold: Threshold base on the number of failed 40 build caused by a test. 41 42 Returns: 43 Whether number of failed build in |failed_result_tuple_list| is equal to 44 or more than |build_fail_total_number_threshold|. 45 """ 46 unique_build_ids = set() 47 for result in failed_result_tuple_list: 48 if '/' in result.build_url: 49 unique_build_ids.add(result.build_url.split('/')[-1]) 50 if len(unique_build_ids) >= build_fail_total_number_threshold: 51 return True 52 return False 53 54 55def OverFailedBuildByDayThreshold( 56 failed_result_tuple_list: List[ct.ResultTupleType], 57 build_fail_consecutive_day_threshold: int) -> bool: 58 """Check if the max number of build fail in consecutive date 59 is equal to or more than |build_fail_consecutive_day_threshold|. 60 61 Args: 62 failed_result_tuple_list: A list of ct.ResultTupleType failed test result. 63 build_fail_consecutive_day_threshold: Threshold base on the number of 64 consecutive days that a test caused build fail. 65 66 Returns: 67 Whether the max number of build fail in consecutive date 68 is equal to or more than |build_fail_consecutive_day_threshold|. 69 """ 70 dates = {t.date: False for t in failed_result_tuple_list} 71 72 for cur_date, is_checked in dates.items(): 73 # A beginning point. 74 if not is_checked: 75 count = 1 76 77 while count < build_fail_consecutive_day_threshold: 78 new_date = cur_date + timedelta(days=count) 79 if new_date in dates: 80 count += 1 81 # Mark checked date. 82 dates[new_date] = True 83 else: 84 break 85 86 if count >= build_fail_consecutive_day_threshold: 87 return True 88 89 return False 90 91 92class ExpectationProcessor(): 93 # pylint: disable=too-many-locals 94 def IterateThroughResultsForUser(self, result_map: ct.AggregatedResultsType, 95 group_by_tags: bool, 96 include_all_tags: bool) -> None: 97 """Iterates over |result_map| for the user to provide input. 98 99 For each unique result, user will be able to decide whether to ignore it (do 100 nothing), mark as flaky (add RetryOnFailure expectation), or mark as failing 101 (add Failure expectation). If the latter two are chosen, they can also 102 associate a bug with the new expectation. 103 104 Args: 105 result_map: Aggregated query results from results.AggregateResults to 106 iterate over. 107 group_by_tags: A boolean denoting whether to attempt to group expectations 108 by tags or not. If True, expectations will be added after an existing 109 expectation whose tags are the largest subset of the produced tags. If 110 False, new expectations will be appended to the end of the file. 111 include_all_tags: A boolean denoting whether all tags should be used for 112 expectations or only the most specific ones. 113 """ 114 typ_tag_ordered_result_map = self._ReorderMapByTypTags(result_map) 115 for suite, test_map in result_map.items(): 116 if self.IsSuiteUnsupported(suite): 117 continue 118 for test, tag_map in test_map.items(): 119 for typ_tags, build_url_list in tag_map.items(): 120 121 print('') 122 print('Suite: %s' % suite) 123 print('Test: %s' % test) 124 print('Configuration:\n %s' % '\n '.join(typ_tags)) 125 print('Failed builds:\n %s' % '\n '.join(build_url_list)) 126 127 other_failures_for_test = self.FindFailuresInSameTest( 128 result_map, suite, test, typ_tags) 129 if other_failures_for_test: 130 print('Other failures in same test found on other configurations') 131 for (tags, failure_count) in other_failures_for_test: 132 print(' %d failures on %s' % (failure_count, ' '.join(tags))) 133 134 other_failures_for_config = self.FindFailuresInSameConfig( 135 typ_tag_ordered_result_map, suite, test, typ_tags) 136 if other_failures_for_config: 137 print('Other failures on same configuration found in other tests') 138 for (name, failure_count) in other_failures_for_config: 139 print(' %d failures in %s' % (failure_count, name)) 140 141 expected_result, bug = self.PromptUserForExpectationAction() 142 if not expected_result: 143 continue 144 145 self.ModifyFileForResult(suite, test, typ_tags, bug, expected_result, 146 group_by_tags, include_all_tags) 147 148 # pylint: enable=too-many-locals 149 150 # pylint: disable=too-many-locals,too-many-arguments 151 def IterateThroughResultsWithThresholds( 152 self, result_map: ct.AggregatedResultsType, group_by_tags: bool, 153 result_counts: ct.ResultCountType, ignore_threshold: float, 154 flaky_threshold: float, include_all_tags: bool) -> None: 155 """Iterates over |result_map| and generates expectations based off 156 thresholds. 157 158 Args: 159 result_map: Aggregated query results from results.AggregateResults to 160 iterate over. 161 group_by_tags: A boolean denoting whether to attempt to group expectations 162 by tags or not. If True, expectations will be added after an existing 163 expectation whose tags are the largest subset of the produced tags. If 164 False, new expectations will be appended to the end of the file. 165 result_counts: A dict in the format output by queries.GetResultCounts. 166 ignore_threshold: A float containing the fraction of failed tests under 167 which failures will be ignored. 168 flaky_threshold: A float containing the fraction of failed tests under 169 which failures will be suppressed with RetryOnFailure and above which 170 will be suppressed with Failure. 171 include_all_tags: A boolean denoting whether all tags should be used for 172 expectations or only the most specific ones. 173 """ 174 assert isinstance(ignore_threshold, float) 175 assert isinstance(flaky_threshold, float) 176 for suite, test_map in result_map.items(): 177 if self.IsSuiteUnsupported(suite): 178 continue 179 for test, tag_map in test_map.items(): 180 for typ_tags, build_url_list in tag_map.items(): 181 failure_count = len(build_url_list) 182 total_count = result_counts[typ_tags][test] 183 fraction = failure_count / total_count 184 if fraction < ignore_threshold: 185 continue 186 expected_result = self.GetExpectedResult(fraction, flaky_threshold) 187 if expected_result: 188 self.ModifyFileForResult(suite, test, typ_tags, '', expected_result, 189 group_by_tags, include_all_tags) 190 191 def CreateExpectationsForAllResults( 192 self, result_map: ct.AggregatedStatusResultsType, group_by_tags: bool, 193 include_all_tags: bool, build_fail_total_number_threshold: int, 194 build_fail_consecutive_day_threshold: int) -> None: 195 """Iterates over |result_map|, selects tests that hit all 196 build-fail*-thresholds and adds expectations for their results. Same 197 test in all builders that caused build fail must be over all threshold 198 requirement. 199 200 Args: 201 result_map: Aggregated query results from results.AggregateResults to 202 iterate over. 203 group_by_tags: A boolean denoting whether to attempt to group expectations 204 by tags or not. If True, expectations will be added after an existing 205 expectation whose tags are the largest subset of the produced tags. If 206 False, new expectations will be appended to the end of the file. 207 include_all_tags: A boolean denoting whether all tags should be used for 208 expectations or only the most specific ones. 209 build_fail_total_number_threshold: Threshold based on the number of 210 failed builds caused by a test. Add to the expectations, if actual 211 is equal to or more than this threshold. All build-fail*-thresholds 212 must be hit in order for a test to actually be suppressed. 213 build_fail_consecutive_day_threshold: Threshold based on the number of 214 consecutive days that a test caused build fail. Add to the 215 expectations, if the consecutive days that it caused build fail 216 are equal to or more than this. All build-fail*-thresholds 217 must be hit in order for a test to actually be suppressed. 218 """ 219 for suite, test_map in result_map.items(): 220 if self.IsSuiteUnsupported(suite): 221 continue 222 for test, tag_map in test_map.items(): 223 # Same test in all builders that caused build fail must be over all 224 # threshold requirement. 225 all_results = list(itertools.chain(*tag_map.values())) 226 if (not OverFailedBuildThreshold(all_results, 227 build_fail_total_number_threshold) 228 or not OverFailedBuildByDayThreshold( 229 all_results, build_fail_consecutive_day_threshold)): 230 continue 231 for typ_tags, result_tuple_list in tag_map.items(): 232 status = set() 233 for test_result in result_tuple_list: 234 # Should always add a pass to all flaky web tests in 235 # TestsExpectation that have passed runs. 236 status.add('Pass') 237 if test_result.status == ct.ResultStatus.CRASH: 238 status.add('Crash') 239 elif test_result.status == ct.ResultStatus.FAIL: 240 status.add('Failure') 241 elif test_result.status == ct.ResultStatus.ABORT: 242 status.add('Timeout') 243 if status: 244 status_list = list(status) 245 status_list.sort() 246 self.ModifyFileForResult(suite, test, typ_tags, '', 247 ' '.join(status_list), group_by_tags, 248 include_all_tags) 249 250 # pylint: enable=too-many-locals,too-many-arguments 251 252 def FindFailuresInSameTest(self, result_map: ct.AggregatedResultsType, 253 target_suite: str, target_test: str, 254 target_typ_tags: ct.TagTupleType 255 ) -> List[Tuple[ct.TagTupleType, int]]: 256 """Finds all other failures that occurred in the given test. 257 258 Ignores the failures for the test on the same configuration. 259 260 Args: 261 result_map: Aggregated query results from results.AggregateResults. 262 target_suite: A string containing the test suite being checked. 263 target_test: A string containing the target test case being checked. 264 target_typ_tags: A tuple of strings containing the typ tags that the 265 failure took place on. 266 267 Returns: 268 A list of tuples (typ_tags, count). |typ_tags| is a list of strings 269 defining a configuration the specified test failed on. |count| is how many 270 times the test failed on that configuration. 271 """ 272 assert isinstance(target_typ_tags, tuple) 273 other_failures = [] 274 tag_map = result_map.get(target_suite, {}).get(target_test, {}) 275 for typ_tags, build_url_list in tag_map.items(): 276 if typ_tags == target_typ_tags: 277 continue 278 other_failures.append((typ_tags, len(build_url_list))) 279 return other_failures 280 281 def FindFailuresInSameConfig( 282 self, typ_tag_ordered_result_map: TagOrderedAggregateResultType, 283 target_suite: str, target_test: str, 284 target_typ_tags: ct.TagTupleType) -> List[Tuple[str, int]]: 285 """Finds all other failures that occurred on the given configuration. 286 287 Ignores the failures for the given test on the given configuration. 288 289 Args: 290 typ_tag_ordered_result_map: Aggregated query results from 291 results.AggregateResults that have been reordered using 292 _ReorderMapByTypTags. 293 target_suite: A string containing the test suite the original failure was 294 found in. 295 target_test: A string containing the test case the original failure was 296 found in. 297 target_typ_tags: A tuple of strings containing the typ tags defining the 298 configuration to find failures for. 299 300 Returns: 301 A list of tuples (full_name, count). |full_name| is a string containing a 302 test suite and test case concatenated together. |count| is how many times 303 |full_name| failed on the configuration specified by |target_typ_tags|. 304 """ 305 assert isinstance(target_typ_tags, tuple) 306 other_failures = [] 307 suite_map = typ_tag_ordered_result_map.get(target_typ_tags, {}) 308 for suite, test_map in suite_map.items(): 309 for test, build_url_list in test_map.items(): 310 if suite == target_suite and test == target_test: 311 continue 312 full_name = '%s.%s' % (suite, test) 313 other_failures.append((full_name, len(build_url_list))) 314 return other_failures 315 316 def _ReorderMapByTypTags(self, result_map: ct.AggregatedResultsType 317 ) -> TagOrderedAggregateResultType: 318 """Rearranges|result_map| to use typ tags as the top level keys. 319 320 Args: 321 result_map: Aggregated query results from results.AggregateResults 322 323 Returns: 324 A dict containing the same contents as |result_map|, but in the following 325 format: 326 { 327 typ_tags (tuple of str): { 328 suite (str): { 329 test (str): build_url_list (list of str), 330 }, 331 }, 332 } 333 """ 334 reordered_map = {} 335 for suite, test_map in result_map.items(): 336 for test, tag_map in test_map.items(): 337 for typ_tags, build_url_list in tag_map.items(): 338 reordered_map.setdefault(typ_tags, 339 {}).setdefault(suite, 340 {})[test] = build_url_list 341 return reordered_map 342 343 def PromptUserForExpectationAction( 344 self) -> Union[Tuple[str, str], Tuple[None, None]]: 345 """Prompts the user on what to do to handle a failure. 346 347 Returns: 348 A tuple (expected_result, bug). |expected_result| is a string containing 349 the expected result to use for the expectation, e.g. RetryOnFailure. |bug| 350 is a string containing the bug to use for the expectation. If the user 351 chooses to ignore the failure, both will be None. Otherwise, both are 352 filled, although |bug| may be an empty string if no bug is provided. 353 """ 354 prompt = ('How should this failure be handled? (i)gnore/(r)etry on ' 355 'failure/(f)ailure: ') 356 valid_inputs = ['f', 'i', 'r'] 357 response = input(prompt).lower() 358 while response not in valid_inputs: 359 print('Invalid input, valid inputs are %s' % (', '.join(valid_inputs))) 360 response = input(prompt).lower() 361 362 if response == 'i': 363 return (None, None) 364 expected_result = 'RetryOnFailure' if response == 'r' else 'Failure' 365 366 prompt = ('What is the bug URL that should be associated with this ' 367 'expectation? E.g. crbug.com/1234. ') 368 response = input(prompt) 369 return (expected_result, response) 370 371 # pylint: disable=too-many-locals,too-many-arguments 372 def ModifyFileForResult(self, suite: str, test: str, 373 typ_tags: ct.TagTupleType, bug: str, 374 expected_result: str, group_by_tags: bool, 375 include_all_tags: bool) -> None: 376 """Adds an expectation to the appropriate expectation file. 377 378 Args: 379 suite: A string containing the suite the failure occurred in. 380 test: A string containing the test case the failure occurred in. 381 typ_tags: A tuple of strings containing the typ tags the test produced. 382 bug: A string containing the bug to associate with the new expectation. 383 expected_result: A string containing the expected result to use for the 384 new expectation, e.g. RetryOnFailure. 385 group_by_tags: A boolean denoting whether to attempt to group expectations 386 by tags or not. If True, expectations will be added after an existing 387 expectation whose tags are the largest subset of the produced tags. If 388 False, new expectations will be appended to the end of the file. 389 include_all_tags: A boolean denoting whether all tags should be used for 390 expectations or only the most specific ones. 391 """ 392 expectation_file = self.GetExpectationFileForSuite(suite, typ_tags) 393 if not include_all_tags: 394 typ_tags = self.FilterToMostSpecificTypTags(typ_tags, expectation_file) 395 bug = '%s ' % bug if bug else bug 396 397 def AppendExpectationToEnd(): 398 expectation_line = '%s[ %s ] %s [ %s ]\n' % (bug, ' '.join( 399 self.ProcessTypTagsBeforeWriting(typ_tags)), test, expected_result) 400 with open(expectation_file, 'a') as outfile: 401 outfile.write(expectation_line) 402 403 if group_by_tags: 404 insertion_line, best_matching_tags = ( 405 self.FindBestInsertionLineForExpectation(typ_tags, expectation_file)) 406 if insertion_line == -1: 407 AppendExpectationToEnd() 408 else: 409 # If we've already filtered tags, then use those instead of the "best 410 # matching" ones. 411 tags_to_use = best_matching_tags 412 if not include_all_tags: 413 tags_to_use = typ_tags 414 # enumerate starts at 0 but line numbers start at 1. 415 insertion_line -= 1 416 tags_to_use = list(self.ProcessTypTagsBeforeWriting(tags_to_use)) 417 tags_to_use.sort() 418 expectation_line = '%s[ %s ] %s [ %s ]\n' % (bug, ' '.join(tags_to_use), 419 test, expected_result) 420 with open(expectation_file) as infile: 421 input_contents = infile.read() 422 output_contents = '' 423 for lineno, line in enumerate(input_contents.splitlines(True)): 424 output_contents += line 425 if lineno == insertion_line: 426 output_contents += expectation_line 427 with open(expectation_file, 'w') as outfile: 428 outfile.write(output_contents) 429 else: 430 AppendExpectationToEnd() 431 432 # pylint: enable=too-many-locals,too-many-arguments 433 434 # pylint: disable=too-many-locals 435 def FilterToMostSpecificTypTags(self, typ_tags: ct.TagTupleType, 436 expectation_file: str) -> ct.TagTupleType: 437 """Filters |typ_tags| to the most specific set. 438 439 Assumes that the tags in |expectation_file| are ordered from least specific 440 to most specific within each tag group. 441 442 Args: 443 typ_tags: A tuple of strings containing the typ tags the test produced. 444 expectation_file: A string containing a filepath pointing to the 445 expectation file to filter tags with. 446 447 Returns: 448 A tuple containing the contents of |typ_tags| with only the most specific 449 tag from each tag group remaining. 450 """ 451 with open(expectation_file) as infile: 452 contents = infile.read() 453 454 tag_groups = self.GetTagGroups(contents) 455 num_matches = 0 456 tags_in_same_group = collections.defaultdict(list) 457 for tag in typ_tags: 458 for index, tag_group in enumerate(tag_groups): 459 if tag in tag_group: 460 tags_in_same_group[index].append(tag) 461 num_matches += 1 462 break 463 if num_matches != len(typ_tags): 464 all_tags = set() 465 for group in tag_groups: 466 all_tags |= set(group) 467 raise RuntimeError('Found tags not in expectation file: %s' % 468 ' '.join(set(typ_tags) - all_tags)) 469 470 filtered_tags = [] 471 for index, tags in tags_in_same_group.items(): 472 if len(tags) == 1: 473 filtered_tags.append(tags[0]) 474 else: 475 tag_group = tag_groups[index] 476 best_index = -1 477 for t in tags: 478 i = tag_group.index(t) 479 if i > best_index: 480 best_index = i 481 filtered_tags.append(tag_group[best_index]) 482 483 # Sort to keep order consistent with what we were given. 484 filtered_tags.sort() 485 return tuple(filtered_tags) 486 487 # pylint: enable=too-many-locals 488 489 def FindBestInsertionLineForExpectation(self, typ_tags: ct.TagTupleType, 490 expectation_file: str 491 ) -> Tuple[int, Set[str]]: 492 """Finds the best place to insert an expectation when grouping by tags. 493 494 Args: 495 typ_tags: A tuple of strings containing typ tags that were produced by the 496 failing test. 497 expectation_file: A string containing a filepath to the expectation file 498 to use. 499 500 Returns: 501 A tuple (insertion_line, best_matching_tags). |insertion_line| is an int 502 specifying the line number to insert the expectation into. 503 |best_matching_tags| is a set containing the tags of an existing 504 expectation that was found to be the closest match. If no appropriate 505 line is found, |insertion_line| is -1 and |best_matching_tags| is empty. 506 """ 507 best_matching_tags = set() 508 best_insertion_line = -1 509 with open(expectation_file) as f: 510 content = f.read() 511 list_parser = expectations_parser.TaggedTestListParser(content) 512 for e in list_parser.expectations: 513 expectation_tags = e.tags 514 if not expectation_tags.issubset(typ_tags): 515 continue 516 if len(expectation_tags) > len(best_matching_tags): 517 best_matching_tags = expectation_tags 518 best_insertion_line = e.lineno 519 elif len(expectation_tags) == len(best_matching_tags): 520 if best_insertion_line < e.lineno: 521 best_insertion_line = e.lineno 522 return best_insertion_line, best_matching_tags 523 524 def GetOriginExpectationFileContents(self) -> Dict[str, str]: 525 """Gets expectation file contents from origin/main. 526 527 Returns: 528 A dict of expectation file name (str) -> expectation file contents (str) 529 that are available on origin/main. File paths are relative to the 530 Chromium src dir and are OS paths. 531 """ 532 # Get the path to the expectation file directory in gitiles, i.e. the POSIX 533 # path relative to the Chromium src directory. 534 origin_file_contents = {} 535 expectation_files = self.ListOriginExpectationFiles() 536 for f in expectation_files: 537 filepath_posix = f.replace(os.sep, '/') 538 origin_filepath_url = posixpath.join(GITILES_URL, 539 filepath_posix) + TEXT_FORMAT_ARG 540 response = urllib.request.urlopen(origin_filepath_url).read() 541 decoded_text = base64.b64decode(response).decode('utf-8') 542 # After the URL access maintain all the paths as os paths. 543 origin_file_contents[f] = decoded_text 544 545 return origin_file_contents 546 547 def GetLocalCheckoutExpectationFileContents(self) -> Dict[str, str]: 548 """Gets expectation file contents from the local checkout. 549 550 Returns: 551 A dict of expectation file name (str) -> expectation file contents (str) 552 that are available from the local checkout. File paths are relative to 553 the Chromium src dir and are OS paths. 554 """ 555 local_file_contents = {} 556 expectation_files = self.ListLocalCheckoutExpectationFiles() 557 for f in expectation_files: 558 absolute_filepath = os.path.join(CHROMIUM_SRC_DIR, f) 559 with open(absolute_filepath) as infile: 560 local_file_contents[f] = infile.read() 561 return local_file_contents 562 563 def AssertCheckoutIsUpToDate(self) -> None: 564 """Confirms that the local checkout's expectations are up to date.""" 565 origin_file_contents = self.GetOriginExpectationFileContents() 566 local_file_contents = self.GetLocalCheckoutExpectationFileContents() 567 if origin_file_contents != local_file_contents: 568 raise RuntimeError( 569 'Local Chromium checkout expectations are out of date. Please ' 570 'perform a `git pull`.') 571 572 def GetExpectationFileForSuite(self, suite: str, 573 typ_tags: ct.TagTupleType) -> str: 574 """Finds the correct expectation file for the given suite. 575 576 Args: 577 suite: A string containing the test suite to look for. 578 typ_tags: A tuple of strings containing typ tags that were produced by 579 the failing test. 580 581 Returns: 582 A string containing a filepath to the correct expectation file for 583 |suite|and |typ_tags|. 584 """ 585 raise NotImplementedError 586 587 def ListGitilesDirectory(self, origin_dir: str) -> List[str]: 588 """Gets the list of all files from origin/main under origin_dir. 589 590 Args: 591 origin_dir: A string containing the path to the directory containing 592 expectation files. Path is relative to the Chromium src dir. 593 594 Returns: 595 A list of filename strings under origin_dir. 596 """ 597 origin_dir_url = posixpath.join(GITILES_URL, origin_dir) + TEXT_FORMAT_ARG 598 response = urllib.request.urlopen(origin_dir_url).read() 599 # Response is a base64 encoded, newline-separated list of files in the 600 # directory in the format: `mode file_type hash name` 601 files = [] 602 decoded_text = base64.b64decode(response).decode('utf-8') 603 for line in decoded_text.splitlines(): 604 files.append(line.split()[-1]) 605 return files 606 607 def IsSuiteUnsupported(self, suite: str) -> bool: 608 raise NotImplementedError 609 610 def ListLocalCheckoutExpectationFiles(self) -> List[str]: 611 """Finds the list of all expectation files from the local checkout. 612 613 Returns: 614 A list of strings containing relative file paths to expectation files. 615 OS paths relative to Chromium src dir are returned. 616 """ 617 raise NotImplementedError 618 619 def ListOriginExpectationFiles(self) -> List[str]: 620 """Finds the list of all expectation files from origin/main. 621 622 Returns: 623 A list of strings containing relative file paths to expectation files. 624 OS paths are relative to Chromium src directory. 625 """ 626 raise NotImplementedError 627 628 def GetTagGroups(self, contents: str) -> List[List[str]]: 629 tag_groups = [] 630 for match in TAG_GROUP_REGEX.findall(contents): 631 tag_groups.append(match.strip().replace('#', '').split()) 632 return tag_groups 633 634 def GetExpectedResult(self, fraction: float, flaky_threshold: float) -> str: 635 raise NotImplementedError 636 637 def ProcessTypTagsBeforeWriting(self, 638 typ_tags: ct.TagTupleType) -> ct.TagTupleType: 639 return typ_tags 640