• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2020 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"""Various custom data types for use throughout the unexpected pass finder."""
5
6from __future__ import print_function
7
8import collections
9import copy
10import fnmatch
11import logging
12from typing import (Any, Dict, FrozenSet, Generator, Iterable, List, Optional,
13                    Set, Tuple, Type, Union)
14
15import six
16
17from typ import expectations_parser
18
19FULL_PASS = 1
20NEVER_PASS = 2
21PARTIAL_PASS = 3
22
23# Allow different unexpected pass finder implementations to register custom
24# data types if necessary. These are set to the base versions at the end of the
25# file.
26Expectation = None
27Result = None
28BuildStats = None
29TestExpectationMap = None
30
31# Type hinting aliases.
32ResultListType = List['BaseResult']
33ResultSetType = Set['BaseResult']
34
35
36def SetExpectationImplementation(impl: Type['BaseExpectation']) -> None:
37  global Expectation
38  assert issubclass(impl, BaseExpectation)
39  Expectation = impl
40
41
42def SetResultImplementation(impl: Type['BaseResult']) -> None:
43  global Result
44  assert issubclass(impl, BaseResult)
45  Result = impl
46
47
48def SetBuildStatsImplementation(impl: Type['BaseBuildStats']) -> None:
49  global BuildStats
50  assert issubclass(impl, BaseBuildStats)
51  BuildStats = impl
52
53
54def SetTestExpectationMapImplementation(impl: Type['BaseTestExpectationMap']
55                                        ) -> None:
56  global TestExpectationMap
57  assert issubclass(impl, BaseTestExpectationMap)
58  TestExpectationMap = impl
59
60
61class BaseExpectation():
62  """Container for a test expectation.
63
64  Similar to typ's expectations_parser.Expectation class, but with unnecessary
65  data stripped out and made hashable.
66
67  The data contained in an Expectation is equivalent to a single line in an
68  expectation file.
69  """
70
71  def __init__(self,
72               test: str,
73               tags: Iterable[str],
74               expected_results: Union[str, Iterable[str]],
75               bug: Optional[str] = None):
76    self.test = test
77    self.tags = frozenset(tags)
78    self.bug = bug or ''
79    if isinstance(expected_results, str):
80      self.expected_results = frozenset([expected_results])
81    else:
82      self.expected_results = frozenset(expected_results)
83
84    # We're going to be making a lot of comparisons, and fnmatch is *much*
85    # slower (~40x from rough testing) than a straight comparison, so only use
86    # it if necessary.
87    if self._IsWildcard():
88      self._comp = self._CompareWildcard
89    else:
90      self._comp = self._CompareNonWildcard
91
92  def __eq__(self, other: Any) -> bool:
93    return (isinstance(other, BaseExpectation) and self.test == other.test
94            and self.tags == other.tags
95            and self.expected_results == other.expected_results
96            and self.bug == other.bug)
97
98  def __ne__(self, other: Any) -> bool:
99    return not self.__eq__(other)
100
101  def __hash__(self) -> int:
102    return hash((self.test, self.tags, self.expected_results, self.bug))
103
104  def _IsWildcard(self) -> bool:
105    # This logic is the same as typ's expectation parser.
106    return not self.test.endswith('\\*') and self.test.endswith('*')
107
108  def _CompareWildcard(self, result_test_name: str) -> bool:
109    return fnmatch.fnmatch(result_test_name, self.test)
110
111  def _CompareNonWildcard(self, result_test_name: str) -> bool:
112    return result_test_name == self.test
113
114  def AppliesToResult(self, result: 'BaseResult') -> bool:
115    """Checks whether this expectation should have applied to |result|.
116
117    An expectation applies to a result if the test names match (including
118    wildcard expansion) and the expectation's tags are a subset of the result's
119    tags.
120
121    Args:
122      result: A Result instance to check against.
123
124    Returns:
125      True if |self| applies to |result|, otherwise False.
126    """
127    assert isinstance(result, BaseResult)
128    return (self._comp(result.test) and self.tags <= result.tags)
129
130  def MaybeAppliesToTest(self, test_name: str) -> bool:
131    """Similar to AppliesToResult, but used to do initial filtering.
132
133    Args:
134      test_name: A string containing the name of a test.
135
136    Returns:
137      True if |self| could apply to a test named |test_name|, otherwise False.
138    """
139    return self._comp(test_name)
140
141  def AsExpectationFileString(self) -> str:
142    """Gets a string representation of the expectation usable in files.
143
144    Returns:
145      A string containing all of the information in the expectation in a format
146      that is compatible with expectation files.
147    """
148    typ_expectation = expectations_parser.Expectation(
149        reason=self.bug,
150        test=self.test,
151        raw_tags=self._ProcessTagsForFileUse(),
152        raw_results=list(self.expected_results),
153        # This logic is normally handled by typ when parsing a file, but since
154        # we're manually creating an expectation, we have to specify the
155        # glob-ness manually.
156        is_glob=self._IsWildcard())
157    return typ_expectation.to_string()
158
159  def _ProcessTagsForFileUse(self) -> List[str]:
160    """Process tags to be suitable for use in expectation files.
161
162    The tags we store should always be valid, but may not adhere to the style
163    actually used by the expectation files. For example, tags are stored
164    internally in lower case, but the expectation files may use capitalized
165    tags.
166
167    Returns:
168      A list of strings containing the contents of |self.tags|, but potentially
169      formatted a certain way.
170    """
171    return list(self.tags)
172
173
174class BaseResult():
175  """Container for a test result.
176
177  Contains the minimal amount of data necessary to describe/identify a result
178  from ResultDB for the purposes of the unexpected pass finder.
179  """
180
181  def __init__(self, test: str, tags: Iterable[str], actual_result: str,
182               step: str, build_id: str):
183    """
184    Args:
185      test: A string containing the name of the test.
186      tags: An iterable containing the typ tags for the result.
187      actual_result: The actual result of the test as a string.
188      step: A string containing the name of the step on the builder.
189      build_id: A string containing the Buildbucket ID for the build this result
190          came from.
191    """
192    self.test = test
193    self.tags = frozenset(tags)
194    self.actual_result = actual_result
195    self.step = step
196    self.build_id = build_id
197
198  def __eq__(self, other: Any) -> bool:
199    return (isinstance(other, BaseResult) and self.test == other.test
200            and self.tags == other.tags
201            and self.actual_result == other.actual_result
202            and self.step == other.step and self.build_id == other.build_id)
203
204  def __ne__(self, other: Any) -> bool:
205    return not self.__eq__(other)
206
207  def __hash__(self) -> int:
208    return hash(
209        (self.test, self.tags, self.actual_result, self.step, self.build_id))
210
211
212class BaseBuildStats():
213  """Container for keeping track of a builder's pass/fail stats."""
214
215  def __init__(self):
216    self.passed_builds = 0
217    self.total_builds = 0
218    self.failure_links = set()
219    self.tag_sets = set()
220
221  @property
222  def failed_builds(self) -> int:
223    return self.total_builds - self.passed_builds
224
225  @property
226  def did_fully_pass(self) -> bool:
227    return self.passed_builds == self.total_builds
228
229  @property
230  def did_never_pass(self) -> bool:
231    return self.failed_builds == self.total_builds
232
233  def AddPassedBuild(self, tags: FrozenSet[str]) -> None:
234    self.passed_builds += 1
235    self.total_builds += 1
236    self.tag_sets.add(tags)
237
238  def AddFailedBuild(self, build_id: str, tags: FrozenSet[str]) -> None:
239    self.total_builds += 1
240    self.failure_links.add(BuildLinkFromBuildId(build_id))
241    self.tag_sets.add(tags)
242
243  def GetStatsAsString(self) -> str:
244    return '(%d/%d passed)' % (self.passed_builds, self.total_builds)
245
246  # pylint:disable=unused-argument
247  def NeverNeededExpectation(self, expectation: BaseExpectation) -> bool:
248    """Returns whether the results tallied in |self| never needed |expectation|.
249
250    Args:
251      expectation: An Expectation object that |stats| is located under.
252
253    Returns:
254      True if all the results tallied in |self| would have passed without
255      |expectation| being present. Otherwise, False.
256    """
257    return self.did_fully_pass
258  # pylint:enable=unused-argument
259
260  # pylint:disable=unused-argument
261  def AlwaysNeededExpectation(self, expectation: BaseExpectation) -> bool:
262    """Returns whether the results tallied in |self| always needed |expectation.
263
264    Args:
265      expectation: An Expectation object that |stats| is located under.
266
267    Returns:
268      True if all the results tallied in |self| would have failed without
269      |expectation| being present. Otherwise, False.
270    """
271    return self.did_never_pass
272  # pylint:enable=unused-argument
273
274  def __eq__(self, other: Any) -> bool:
275    return (isinstance(other, BuildStats)
276            and self.passed_builds == other.passed_builds
277            and self.total_builds == other.total_builds
278            and self.failure_links == other.failure_links
279            and self.tag_sets == other.tag_sets)
280
281  def __ne__(self, other: Any) -> bool:
282    return not self.__eq__(other)
283
284
285def BuildLinkFromBuildId(build_id: str) -> str:
286  return 'http://ci.chromium.org/b/%s' % build_id
287
288
289# These explicit overrides could likely be replaced by using regular dicts with
290# type hinting in Python 3. Based on https://stackoverflow.com/a/2588648, this
291# should cover all cases where the dict can be modified.
292class BaseTypedMap(dict):
293  """A base class for typed dictionaries.
294
295  Any child classes that override __setitem__ will have any modifications to the
296  dictionary go through the type checking in __setitem__.
297  """
298
299  def __init__(self, *args, **kwargs):  # pylint:disable=super-init-not-called
300    self.update(*args, **kwargs)
301
302  def update(self, *args, **kwargs) -> None:
303    if args:
304      assert len(args) == 1
305      other = dict(args[0])
306      for k, v in other.items():
307        self[k] = v
308    for k, v in kwargs.items():
309      self[k] = v
310
311  def setdefault(self, key: Any, value: Any = None) -> Any:
312    if key not in self:
313      self[key] = value
314    return self[key]
315
316  def _value_type(self) -> type:
317    raise NotImplementedError()
318
319  def IterToValueType(self, value_type: type) -> Generator[tuple, None, None]:
320    """Recursively iterates over contents until |value_type| is found.
321
322    Used to get rid of nested loops, instead using a single loop that
323    automatically iterates through all the contents at a certain depth.
324
325    Args:
326      value_type: The type to recurse to and then iterate over. For example,
327          "BuilderStepMap" would result in iterating over the BuilderStepMap
328          values, meaning that the returned generator would create tuples in the
329          form (test_name, expectation, builder_map).
330
331    Returns:
332      A generator that yields tuples. The length and content of the tuples will
333      vary depending on |value_type|. For example, using "BuilderStepMap" would
334      result in tuples of the form (test_name, expectation, builder_map), while
335      "BuildStats" would result in (test_name, expectation, builder_name,
336      step_name, build_stats).
337    """
338    if self._value_type() == value_type:
339      for k, v in self.items():
340        yield k, v
341    else:
342      for k, v in self.items():
343        for nested_value in v.IterToValueType(value_type):
344          yield (k, ) + nested_value
345
346  def Merge(self,
347            other_map: 'BaseTypedMap',
348            reference_map: Optional[dict] = None) -> None:
349    """Merges |other_map| into self.
350
351    Args:
352      other_map: A BaseTypedMap whose contents will be merged into self.
353      reference_map: A dict containing the information that was originally in
354          self. Used for ensuring that a single expectation/builder/step
355          combination is only ever updated once. If None, a copy of self will be
356          used.
357    """
358    assert isinstance(other_map, self.__class__)
359    # We should only ever encounter a single updated BuildStats for an
360    # expectation/builder/step combination. Use the reference map to determine
361    # if a particular BuildStats has already been updated or not.
362    reference_map = reference_map or copy.deepcopy(self)
363    for key, value in other_map.items():
364      if key not in self:
365        self[key] = value
366      else:
367        if isinstance(value, dict):
368          self[key].Merge(value, reference_map.get(key, {}))
369        else:
370          assert isinstance(value, BuildStats)
371          # Ensure we haven't updated this BuildStats already. If the reference
372          # map doesn't have a corresponding BuildStats, then base_map shouldn't
373          # have initially either, and thus it would have been added before
374          # reaching this point. Otherwise, the two values must match, meaning
375          # that base_map's BuildStats hasn't been updated yet.
376          reference_stats = reference_map.get(key, None)
377          assert reference_stats is not None
378          assert reference_stats == self[key]
379          self[key] = value
380
381
382class BaseTestExpectationMap(BaseTypedMap):
383  """Typed map for string types -> ExpectationBuilderMap.
384
385  This results in a dict in the following format:
386  {
387    expectation_file1 (str): {
388      expectation1 (data_types.Expectation): {
389        builder_name1 (str): {
390          step_name1 (str): stats1 (data_types.BuildStats),
391          step_name2 (str): stats2 (data_types.BuildStats),
392          ...
393        },
394        builder_name2 (str): { ... },
395      },
396      expectation2 (data_types.Expectation): { ... },
397      ...
398    },
399    expectation_file2 (str): { ... },
400    ...
401  }
402  """
403
404  def __setitem__(self, key: str, value: 'ExpectationBuilderMap') -> None:
405    assert IsStringType(key)
406    assert isinstance(value, ExpectationBuilderMap)
407    super().__setitem__(key, value)
408
409  def _value_type(self) -> type:
410    return ExpectationBuilderMap
411
412  def IterBuilderStepMaps(
413      self
414  ) -> Generator[Tuple[str, BaseExpectation, 'BuilderStepMap'], None, None]:
415    """Iterates over all BuilderStepMaps contained in the map.
416
417    Returns:
418      A generator yielding tuples in the form (expectation_file (str),
419      expectation (Expectation), builder_map (BuilderStepMap))
420    """
421    return self.IterToValueType(BuilderStepMap)
422
423  def AddResultList(self,
424                    builder: str,
425                    results: ResultListType,
426                    expectation_files: Optional[Iterable[str]] = None
427                    ) -> ResultListType:
428    """Adds |results| to |self|.
429
430    Args:
431      builder: A string containing the builder |results| came from. Should be
432          prefixed with something to distinguish between identically named CI
433          and try builders.
434      results: A list of data_types.Result objects corresponding to the ResultDB
435          data queried for |builder|.
436      expectation_files: An iterable of expectation file names that these
437          results could possibly apply to. If None, then expectations from all
438          known expectation files will be used.
439
440    Returns:
441      A list of data_types.Result objects who did not have a matching
442      expectation in |self|.
443    """
444    failure_results = set()
445    pass_results = set()
446    unmatched_results = []
447    for r in results:
448      if r.actual_result == 'Pass':
449        pass_results.add(r)
450      else:
451        failure_results.add(r)
452
453    # Remove any cases of failure -> pass from the passing set. If a test is
454    # flaky, we get both pass and failure results for it, so we need to remove
455    # the any cases of a pass result having a corresponding, earlier failure
456    # result.
457    modified_failing_retry_results = set()
458    for r in failure_results:
459      modified_failing_retry_results.add(
460          Result(r.test, r.tags, 'Pass', r.step, r.build_id))
461    pass_results -= modified_failing_retry_results
462
463    # Group identically named results together so we reduce the number of
464    # comparisons we have to make.
465    all_results = pass_results | failure_results
466    grouped_results = collections.defaultdict(list)
467    for r in all_results:
468      grouped_results[r.test].append(r)
469
470    matched_results = self._AddGroupedResults(grouped_results, builder,
471                                              expectation_files)
472    unmatched_results = list(all_results - matched_results)
473
474    return unmatched_results
475
476  def _AddGroupedResults(self, grouped_results: Dict[str, ResultListType],
477                         builder: str, expectation_files: Optional[List[str]]
478                         ) -> ResultSetType:
479    """Adds all results in |grouped_results| to |self|.
480
481    Args:
482      grouped_results: A dict mapping test name (str) to a list of
483          data_types.Result objects for that test.
484      builder: A string containing the name of the builder |grouped_results|
485          came from.
486      expectation_files: An iterable of expectation file names that these
487          results could possibly apply to. If None, then expectations from all
488          known expectation files will be used.
489
490    Returns:
491      A set of data_types.Result objects that had at least one matching
492      expectation.
493    """
494    matched_results = set()
495    for test_name, result_list in grouped_results.items():
496      for ef, expectation_map in self.items():
497        if expectation_files is not None and ef not in expectation_files:
498          continue
499        for expectation, builder_map in expectation_map.items():
500          if not expectation.MaybeAppliesToTest(test_name):
501            continue
502          for r in result_list:
503            if expectation.AppliesToResult(r):
504              matched_results.add(r)
505              step_map = builder_map.setdefault(builder, StepBuildStatsMap())
506              stats = step_map.setdefault(r.step, BuildStats())
507              self._AddSingleResult(r, stats)
508    return matched_results
509
510  def _AddSingleResult(self, result: BaseResult, stats: BaseBuildStats) -> None:
511    """Adds |result| to |self|.
512
513    Args:
514      result: A data_types.Result object to add.
515      stats: A data_types.BuildStats object to add the result to.
516    """
517    if result.actual_result == 'Pass':
518      stats.AddPassedBuild(result.tags)
519    else:
520      stats.AddFailedBuild(result.build_id, result.tags)
521
522  def SplitByStaleness(
523      self) -> Tuple['BaseTestExpectationMap', 'BaseTestExpectationMap',
524                     'BaseTestExpectationMap']:
525    """Separates stored data based on expectation staleness.
526
527    Returns:
528      Three TestExpectationMaps (stale_dict, semi_stale_dict, active_dict). All
529      three combined contain the information of |self|. |stale_dict| contains
530      entries for expectations that are no longer being helpful,
531      |semi_stale_dict| contains entries for expectations that might be
532      removable or modifiable, but have at least one failed test run.
533      |active_dict| contains entries for expectations that are preventing
534      failures on all builders they're active on, and thus shouldn't be removed.
535    """
536    stale_dict = TestExpectationMap()
537    semi_stale_dict = TestExpectationMap()
538    active_dict = TestExpectationMap()
539
540    # This initially looks like a good target for using
541    # TestExpectationMap's iterators since there are many nested loops.
542    # However, we need to reset state in different loops, and the alternative of
543    # keeping all the state outside the loop and resetting under certain
544    # conditions ends up being less readable than just using nested loops.
545    for expectation_file, expectation_map in self.items():
546      for expectation, builder_map in expectation_map.items():
547        # A temporary map to hold data so we can later determine whether an
548        # expectation is stale, semi-stale, or active.
549        tmp_map = {
550            FULL_PASS: BuilderStepMap(),
551            NEVER_PASS: BuilderStepMap(),
552            PARTIAL_PASS: BuilderStepMap(),
553        }
554
555        split_stats_map = builder_map.SplitBuildStatsByPass(expectation)
556        for builder_name, (fully_passed, never_passed,
557                           partially_passed) in split_stats_map.items():
558          if fully_passed:
559            tmp_map[FULL_PASS][builder_name] = fully_passed
560          if never_passed:
561            tmp_map[NEVER_PASS][builder_name] = never_passed
562          if partially_passed:
563            tmp_map[PARTIAL_PASS][builder_name] = partially_passed
564
565        def _CopyPassesIntoBuilderMap(builder_map, pass_types):
566          for pt in pass_types:
567            for builder, steps in tmp_map[pt].items():
568              builder_map.setdefault(builder, StepBuildStatsMap()).update(steps)
569
570        # Handle the case of a stale expectation.
571        if not (tmp_map[NEVER_PASS] or tmp_map[PARTIAL_PASS]):
572          builder_map = stale_dict.setdefault(
573              expectation_file,
574              ExpectationBuilderMap()).setdefault(expectation, BuilderStepMap())
575          _CopyPassesIntoBuilderMap(builder_map, [FULL_PASS])
576        # Handle the case of an active expectation.
577        elif not tmp_map[FULL_PASS]:
578          builder_map = active_dict.setdefault(
579              expectation_file,
580              ExpectationBuilderMap()).setdefault(expectation, BuilderStepMap())
581          _CopyPassesIntoBuilderMap(builder_map, [NEVER_PASS, PARTIAL_PASS])
582        # Handle the case of a semi-stale expectation that should be considered
583        # active.
584        elif self._ShouldTreatSemiStaleAsActive(tmp_map):
585          builder_map = active_dict.setdefault(
586              expectation_file,
587              ExpectationBuilderMap()).setdefault(expectation, BuilderStepMap())
588          _CopyPassesIntoBuilderMap(builder_map,
589                                    [FULL_PASS, PARTIAL_PASS, NEVER_PASS])
590        # Handle the case of a semi-stale expectation.
591        else:
592          # TODO(crbug.com/998329): Sort by pass percentage so it's easier to
593          # find problematic builders without highlighting.
594          builder_map = semi_stale_dict.setdefault(
595              expectation_file,
596              ExpectationBuilderMap()).setdefault(expectation, BuilderStepMap())
597          _CopyPassesIntoBuilderMap(builder_map,
598                                    [FULL_PASS, PARTIAL_PASS, NEVER_PASS])
599    return stale_dict, semi_stale_dict, active_dict
600
601  def _ShouldTreatSemiStaleAsActive(self, pass_map: Dict[int, 'BuilderStepMap']
602                                    ) -> bool:
603    """Check if a semi-stale expectation should be treated as active.
604
605    Allows for implementation-specific workarounds.
606
607    Args:
608      pass_map: A dict mapping the FULL/NEVER/PARTIAL_PASS constants to
609          BuilderStepMaps, as used in self.SplitByStaleness().
610
611    Returns:
612      A boolean denoting whether the given results data should be treated as an
613      active expectation instead of a semi-stale one.
614    """
615    del pass_map
616    return False
617
618  def FilterOutUnusedExpectations(self) -> Dict[str, List[BaseExpectation]]:
619    """Filters out any unused Expectations from stored data.
620
621    An Expectation is considered unused if its corresponding dictionary is
622    empty. If removing Expectations results in a top-level test key having an
623    empty dictionary, that test entry will also be removed.
624
625    Returns:
626      A dict from expectation file name (str) to list of unused
627      data_types.Expectation from that file.
628    """
629    logging.info('Filtering out unused expectations')
630    unused = collections.defaultdict(list)
631    unused_count = 0
632    for (expectation_file, expectation,
633         builder_map) in self.IterBuilderStepMaps():
634      if not builder_map:
635        unused[expectation_file].append(expectation)
636        unused_count += 1
637    for expectation_file, expectations in unused.items():
638      for e in expectations:
639        del self[expectation_file][e]
640    logging.debug('Found %d unused expectations', unused_count)
641
642    empty_files = []
643    for expectation_file, expectation_map in self.items():
644      if not expectation_map:
645        empty_files.append(expectation_file)
646    for empty in empty_files:
647      del self[empty]
648    logging.debug('Found %d empty files: %s', len(empty_files), empty_files)
649
650    return unused
651
652
653class ExpectationBuilderMap(BaseTypedMap):
654  """Typed map for Expectation -> BuilderStepMap."""
655
656  def __setitem__(self, key: BaseExpectation, value: 'BuilderStepMap') -> None:
657    assert isinstance(key, BaseExpectation)
658    assert isinstance(value, self._value_type())
659    super().__setitem__(key, value)
660
661  def _value_type(self) -> type:
662    return BuilderStepMap
663
664
665class BuilderStepMap(BaseTypedMap):
666  """Typed map for string types -> StepBuildStatsMap."""
667
668  def __setitem__(self, key: str, value: 'StepBuildStatsMap') -> None:
669    assert IsStringType(key)
670    assert isinstance(value, self._value_type())
671    super().__setitem__(key, value)
672
673  def _value_type(self) -> type:
674    return StepBuildStatsMap
675
676  def SplitBuildStatsByPass(
677      self, expectation: BaseExpectation
678  ) -> Dict[str, Tuple['StepBuildStatsMap', 'StepBuildStatsMap',
679                       'StepBuildStatsMap']]:
680    """Splits the underlying BuildStats data by passing-ness.
681
682    Args:
683      expectation: The Expectation that this BuilderStepMap is located under.
684
685    Returns:
686      A dict mapping builder name to a tuple (fully_passed, never_passed,
687      partially_passed). Each *_passed is a StepBuildStatsMap containing data
688      for the steps that either fully passed on all builds, never passed on any
689      builds, or passed some of the time.
690    """
691    retval = {}
692    for builder_name, step_map in self.items():
693      fully_passed = StepBuildStatsMap()
694      never_passed = StepBuildStatsMap()
695      partially_passed = StepBuildStatsMap()
696
697      for step_name, stats in step_map.items():
698        if stats.NeverNeededExpectation(expectation):
699          assert step_name not in fully_passed
700          fully_passed[step_name] = stats
701        elif stats.AlwaysNeededExpectation(expectation):
702          assert step_name not in never_passed
703          never_passed[step_name] = stats
704        else:
705          assert step_name not in partially_passed
706          partially_passed[step_name] = stats
707      retval[builder_name] = (fully_passed, never_passed, partially_passed)
708    return retval
709
710  def IterBuildStats(
711      self) -> Generator[Tuple[str, str, BaseBuildStats], None, None]:
712    """Iterates over all BuildStats contained in the map.
713
714    Returns:
715      A generator yielding tuples in the form (builder_name (str), step_name
716      (str), build_stats (BuildStats)).
717    """
718    return self.IterToValueType(BuildStats)
719
720
721class StepBuildStatsMap(BaseTypedMap):
722  """Typed map for string types -> BuildStats"""
723
724  def __setitem__(self, key: str, value: BuildStats) -> None:
725    assert IsStringType(key)
726    assert isinstance(value, self._value_type())
727    super().__setitem__(key, value)
728
729  def _value_type(self) -> type:
730    return BuildStats
731
732
733class BuilderEntry():
734  """Simple container for defining a builder."""
735
736  def __init__(self, name: str, builder_type: str, is_internal_builder: bool):
737    """
738    Args:
739      name: A string containing the name of the builder.
740      builder_type: A string containing the type of builder this is, either
741          "ci" or "try".
742      is_internal_builder: A boolean denoting whether the builder is internal or
743          not.
744    """
745    self.name = name
746    self.builder_type = builder_type
747    self.is_internal_builder = is_internal_builder
748
749  @property
750  def project(self) -> str:
751    return 'chrome' if self.is_internal_builder else 'chromium'
752
753  def __eq__(self, other: Any) -> bool:
754    return (isinstance(other, BuilderEntry) and self.name == other.name
755            and self.builder_type == other.builder_type
756            and self.is_internal_builder == other.is_internal_builder)
757
758  def __ne__(self, other: Any) -> bool:
759    return not self.__eq__(other)
760
761  def __hash__(self) -> int:
762    return hash((self.name, self.builder_type, self.is_internal_builder))
763
764
765def IsStringType(s: Any) -> bool:
766  return isinstance(s, six.string_types)
767
768
769Expectation = BaseExpectation
770Result = BaseResult
771BuildStats = BaseBuildStats
772TestExpectationMap = BaseTestExpectationMap
773