• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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