1# Copyright 2014 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4"""
5The Value hierarchy provides a way of representing the values measurements
6produce such that they can be merged across runs, grouped by page, and output
7to different targets.
8
9The core Value concept provides the basic functionality:
10- association with a page, may be none
11- naming and units
12- importance tracking [whether a value will show up on a waterfall or output
13  file by default]
14- other metadata, such as a description of what was measured
15- default conversion to scalar and string
16- merging properties
17
18A page may actually run a few times during a single telemetry session.
19Downstream consumers of test results typically want to group these runs
20together, then compute summary statistics across runs. Value provides the
21Merge* family of methods for this kind of aggregation.
22"""
23import os
24
25from telemetry.core import discover
26from telemetry.core import util
27
28# When converting a Value to its buildbot equivalent, the context in which the
29# value is being interpreted actually affects the conversion. This is insane,
30# but there you have it. There are three contexts in which Values are converted
31# for use by buildbot, represented by these output-intent values.
32PER_PAGE_RESULT_OUTPUT_CONTEXT = 'per-page-result-output-context'
33COMPUTED_PER_PAGE_SUMMARY_OUTPUT_CONTEXT = 'merged-pages-result-output-context'
34SUMMARY_RESULT_OUTPUT_CONTEXT = 'summary-result-output-context'
35
36class Value(object):
37  """An abstract value produced by a telemetry page test.
38  """
39  def __init__(self, page, name, units, important, description,
40               tir_label, grouping_keys):
41    """A generic Value object.
42
43    Args:
44      page: A Page object, may be given as None to indicate that the value
45          represents results for multiple pages.
46      name: A value name string, may contain a dot. Values from the same test
47          with the same prefix before the dot may be considered to belong to
48          the same chart.
49      units: A units string.
50      important: Whether the value is "important". Causes the value to appear
51          by default in downstream UIs.
52      description: A string explaining in human-understandable terms what this
53          value represents.
54      tir_label: The string label of the TimelineInteractionRecord with
55          which this value is associated.
56      grouping_keys: A dict that maps grouping key names to grouping keys.
57    """
58    # TODO(eakuefner): Check story here after migration (crbug.com/442036)
59    if not isinstance(name, basestring):
60      raise ValueError('name field of Value must be string.')
61    if not isinstance(units, basestring):
62      raise ValueError('units field of Value must be string.')
63    if not isinstance(important, bool):
64      raise ValueError('important field of Value must be bool.')
65    if not ((description is None) or isinstance(description, basestring)):
66      raise ValueError('description field of Value must absent or string.')
67    if not ((tir_label is None) or
68            isinstance(tir_label, basestring)):
69      raise ValueError('tir_label field of Value must absent or '
70                       'string.')
71    if not ((grouping_keys is None) or isinstance(grouping_keys, dict)):
72      raise ValueError('grouping_keys field of Value must be absent or dict')
73
74    if grouping_keys is None:
75      grouping_keys = {}
76
77    self.page = page
78    self.name = name
79    self.units = units
80    self.important = important
81    self.description = description
82    self.tir_label = tir_label
83    self.grouping_keys = grouping_keys
84
85  def __eq__(self, other):
86    return hash(self) == hash(other)
87
88  def __hash__(self):
89    return hash(str(self))
90
91  def IsMergableWith(self, that):
92    return (self.units == that.units and
93            type(self) == type(that) and
94            self.important == that.important)
95
96  @classmethod
97  def MergeLikeValuesFromSamePage(cls, values):
98    """Combines the provided list of values into a single compound value.
99
100    When a page runs multiple times, it may produce multiple values. This
101    function is given the same-named values across the multiple runs, and has
102    the responsibility of producing a single result.
103
104    It must return a single Value. If merging does not make sense, the
105    implementation must pick a representative value from one of the runs.
106
107    For instance, it may be given
108        [ScalarValue(page, 'a', 1), ScalarValue(page, 'a', 2)]
109    and it might produce
110        ListOfScalarValues(page, 'a', [1, 2])
111    """
112    raise NotImplementedError()
113
114  @classmethod
115  def MergeLikeValuesFromDifferentPages(cls, values):
116    """Combines the provided values into a single compound value.
117
118    When a full pageset runs, a single value_name will usually end up getting
119    collected for multiple pages. For instance, we may end up with
120       [ScalarValue(page1, 'a',  1),
121        ScalarValue(page2, 'a',  2)]
122
123    This function takes in the values of the same name, but across multiple
124    pages, and produces a single summary result value. In this instance, it
125    could produce a ScalarValue(None, 'a', 1.5) to indicate averaging, or even
126    ListOfScalarValues(None, 'a', [1, 2]) if concatenated output was desired.
127
128    Some results are so specific to a page that they make no sense when
129    aggregated across pages. If merging values of this type across pages is
130    non-sensical, this method may return None.
131    """
132    raise NotImplementedError()
133
134  def _IsImportantGivenOutputIntent(self, output_context):
135    if output_context == PER_PAGE_RESULT_OUTPUT_CONTEXT:
136      return False
137    elif output_context == COMPUTED_PER_PAGE_SUMMARY_OUTPUT_CONTEXT:
138      return self.important
139    elif output_context == SUMMARY_RESULT_OUTPUT_CONTEXT:
140      return self.important
141
142  def GetBuildbotDataType(self, output_context):
143    """Returns the buildbot's equivalent data_type.
144
145    This should be one of the values accepted by perf_tests_results_helper.py.
146    """
147    raise NotImplementedError()
148
149  def GetBuildbotValue(self):
150    """Returns the buildbot's equivalent value."""
151    raise NotImplementedError()
152
153  def GetChartAndTraceNameForPerPageResult(self):
154    chart_name, _ = _ConvertValueNameToChartAndTraceName(self.name)
155    trace_name = self.page.display_name
156    return chart_name, trace_name
157
158  @property
159  def name_suffix(self):
160    """Returns the string after a . in the name, or the full name otherwise."""
161    if '.' in self.name:
162      return self.name.split('.', 1)[1]
163    else:
164      return self.name
165
166  def GetChartAndTraceNameForComputedSummaryResult(
167      self, trace_tag):
168    chart_name, trace_name = (
169        _ConvertValueNameToChartAndTraceName(self.name))
170    if trace_tag:
171      return chart_name, trace_name + trace_tag
172    else:
173      return chart_name, trace_name
174
175  def GetRepresentativeNumber(self):
176    """Gets a single scalar value that best-represents this value.
177
178    Returns None if not possible.
179    """
180    raise NotImplementedError()
181
182  def GetRepresentativeString(self):
183    """Gets a string value that best-represents this value.
184
185    Returns None if not possible.
186    """
187    raise NotImplementedError()
188
189  @staticmethod
190  def GetJSONTypeName():
191    """Gets the typename for serialization to JSON using AsDict."""
192    raise NotImplementedError()
193
194  def AsDict(self):
195    """Pre-serializes a value to a dict for output as JSON."""
196    return self._AsDictImpl()
197
198  def _AsDictImpl(self):
199    d = {
200      'name': self.name,
201      'type': self.GetJSONTypeName(),
202      'units': self.units,
203      'important': self.important
204    }
205
206    if self.description:
207      d['description'] = self.description
208
209    if self.tir_label:
210      d['tir_label'] = self.tir_label
211
212    if self.page:
213      d['page_id'] = self.page.id
214
215    if self.grouping_keys:
216      d['grouping_keys'] = self.grouping_keys
217
218    return d
219
220  def AsDictWithoutBaseClassEntries(self):
221    full_dict = self.AsDict()
222    base_dict_keys = set(self._AsDictImpl().keys())
223
224    # Extracts only entries added by the subclass.
225    return dict([(k, v) for (k, v) in full_dict.iteritems()
226                  if k not in base_dict_keys])
227
228  @staticmethod
229  def FromDict(value_dict, page_dict):
230    """Produces a value from a value dict and a page dict.
231
232    Value dicts are produced by serialization to JSON, and must be accompanied
233    by a dict mapping page IDs to pages, also produced by serialization, in
234    order to be completely deserialized. If deserializing multiple values, use
235    ListOfValuesFromListOfDicts instead.
236
237    value_dict: a dictionary produced by AsDict() on a value subclass.
238    page_dict: a dictionary mapping IDs to page objects.
239    """
240    return Value.ListOfValuesFromListOfDicts([value_dict], page_dict)[0]
241
242  @staticmethod
243  def ListOfValuesFromListOfDicts(value_dicts, page_dict):
244    """Takes a list of value dicts to values.
245
246    Given a list of value dicts produced by AsDict, this method
247    deserializes the dicts given a dict mapping page IDs to pages.
248    This method performs memoization for deserializing a list of values
249    efficiently, where FromDict is meant to handle one-offs.
250
251    values: a list of value dicts produced by AsDict() on a value subclass.
252    page_dict: a dictionary mapping IDs to page objects.
253    """
254    value_dir = os.path.dirname(__file__)
255    value_classes = discover.DiscoverClasses(
256        value_dir, util.GetTelemetryDir(),
257        Value, index_by_class_name=True)
258
259    value_json_types = dict((value_classes[x].GetJSONTypeName(), x) for x in
260        value_classes)
261
262    values = []
263    for value_dict in value_dicts:
264      value_class = value_classes[value_json_types[value_dict['type']]]
265      assert 'FromDict' in value_class.__dict__, \
266             'Subclass doesn\'t override FromDict'
267      values.append(value_class.FromDict(value_dict, page_dict))
268
269    return values
270
271  @staticmethod
272  def GetConstructorKwArgs(value_dict, page_dict):
273    """Produces constructor arguments from a value dict and a page dict.
274
275    Takes a dict parsed from JSON and an index of pages and recovers the
276    keyword arguments to be passed to the constructor for deserializing the
277    dict.
278
279    value_dict: a dictionary produced by AsDict() on a value subclass.
280    page_dict: a dictionary mapping IDs to page objects.
281    """
282    d = {
283      'name': value_dict['name'],
284      'units': value_dict['units']
285    }
286
287    description = value_dict.get('description', None)
288    if description:
289      d['description'] = description
290    else:
291      d['description'] = None
292
293    page_id = value_dict.get('page_id', None)
294    if page_id is not None:
295      d['page'] = page_dict[int(page_id)]
296    else:
297      d['page'] = None
298
299    d['important'] = False
300
301    tir_label = value_dict.get('tir_label', None)
302    if tir_label:
303      d['tir_label'] = tir_label
304    else:
305      d['tir_label'] = None
306
307    grouping_keys = value_dict.get('grouping_keys', None)
308    if grouping_keys:
309      d['grouping_keys'] = grouping_keys
310    else:
311      d['grouping_keys'] = None
312
313    return d
314
315
316def MergedTirLabel(values):
317  """Returns the tir_label that should be applied to a merge of values.
318
319  As of TBMv2, we encounter situations where we need to merge values with
320  different tir_labels because Telemetry's tir_label field is being used to
321  store story keys for system health stories. As such, when merging, we want to
322  take the common tir_label if all values share the same label (legacy
323  behavior), or have no tir_label if not.
324
325  Args:
326    values: a list of Value instances
327
328  Returns:
329    The tir_label that would be set on the merge of |values|.
330  """
331  assert len(values) > 0
332  v0 = values[0]
333
334  first_tir_label = v0.tir_label
335  if all(v.tir_label == first_tir_label for v in values):
336    return first_tir_label
337  else:
338    return None
339
340
341def ValueNameFromTraceAndChartName(trace_name, chart_name=None):
342  """Mangles a trace name plus optional chart name into a standard string.
343
344  A value might just be a bareword name, e.g. numPixels. In that case, its
345  chart may be None.
346
347  But, a value might also be intended for display with other values, in which
348  case the chart name indicates that grouping. So, you might have
349  screen.numPixels, screen.resolution, where chartName='screen'.
350  """
351  assert trace_name != 'url', 'The name url cannot be used'
352  if chart_name:
353    return '%s.%s' % (chart_name, trace_name)
354  else:
355    assert '.' not in trace_name, ('Trace names cannot contain "." with an '
356        'empty chart_name since this is used to delimit chart_name.trace_name.')
357    return trace_name
358
359
360def _ConvertValueNameToChartAndTraceName(value_name):
361  """Converts a value_name into the equivalent chart-trace name pair.
362
363  Buildbot represents values by the measurement name and an optional trace name,
364  whereas telemetry represents values with a chart_name.trace_name convention,
365  where chart_name is optional. This convention is also used by chart_json.
366
367  This converts from the telemetry convention to the buildbot convention,
368  returning a 2-tuple (measurement_name, trace_name).
369  """
370  if '.' in value_name:
371    return value_name.split('.', 1)
372  else:
373    return value_name, value_name
374