• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#/usr/bin/env python3
2#
3# Copyright (C) 2018 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may not
6# use this file except in compliance with the License. You may obtain a copy of
7# the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations under
15# the License.
16
17#
18# Copyright 2009 Google Inc. All Rights Reserved.
19"""Lightweight Sponge client, supporting upload via the HTTP Redirector.
20
21Does not depend on protobufs, Stubby, works on Windows, builds without blaze.
22"""
23
24__author__ = 'klm@google.com (Michael Klepikov)'
25
26import collections
27import os
28import re
29import socket
30import time
31
32try:
33    import httpclient as httplib
34except ImportError:
35    import httplib
36
37try:
38    import StringIO
39except ImportError:
40    from io import StringIO
41
42try:
43    import google3  # pylint: disable=g-import-not-at-top
44    from google3.testing.coverage.util import bitfield  # pylint: disable=g-import-not-at-top
45except ImportError:
46    pass  # Running outside of google3
47
48import SimpleXMLWriter  # pylint: disable=g-import-not-at-top
49
50
51class Entity(object):
52    """Base class for all Sponge client entities. Provides XML s11n basics."""
53
54    def WriteXmlToStream(self, ostream, encoding='UTF-8'):
55        """Writes out all attributes with string/numeric value to supplied ostream.
56
57    Args:
58      ostream: A file or file-like object. This object must implement a write
59               method.
60      encoding: Optionally specify encoding to be used.
61    """
62        xml_writer = SimpleXMLWriter.XMLWriter(ostream, encoding)
63        self.WriteXml(xml_writer)
64
65    def WriteXml(self, xml_writer):
66        """Writes out all attributes that have a string or numeric value.
67
68    Args:
69      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
70    """
71        for attr_name in dir(self):  # Guaranteed sorted alphabetically
72            assert attr_name
73            if attr_name.startswith(
74                    '_') or attr_name[0].upper() == attr_name[0]:
75                continue  # Skip non-public attributes and public constants
76            if hasattr(self, '_permitted_attributes'):
77                assert attr_name in self._permitted_attributes
78            if (hasattr(self, '_custom_write_attributes')
79                    and attr_name in self._custom_write_attributes):
80                # An attribute that has custom serialization code
81                continue
82            value = self.__getattribute__(attr_name)
83            if callable(value):
84                continue  # Skip methods
85            Entity._WriteValue(xml_writer, attr_name, value)
86
87    def GetXmlString(self):
88        """Returns a string with XML produced by WriteXml()."""
89        xml_out = StringIO.StringIO()
90        self.WriteXmlToStream(xml_out)
91        xml_str = xml_out.getvalue()
92        xml_out.close()
93        return xml_str
94
95    @staticmethod
96    def _WriteValue(xml_writer, name, value):
97        if value is None:
98            return  # Do not serialize None (but do serialize 0 or empty string)
99        elif isinstance(value, unicode):
100            xml_writer.element(name, value)  # Will write out as UTF-8
101        elif isinstance(value, str):
102            # A non-Unicode string. By default the encoding is 'ascii',
103            # where 8-bit characters cause an encoding exception
104            # when a protobuf encodes itself on the HTTP Redirector side.
105            # Force 'latin' encoding, which allows 8-bit chars.
106            # Still it's only a guess which could be wrong, so use errors='replace'
107            # to produce an 'invalid character' Unicode placeholder in such cases.
108            # For the caller, the cleanest thing to do is pass a proper
109            # Unicode string if it may contain international characters.
110            xml_writer.element(
111                name, unicode(value, encoding='latin', errors='replace'))
112        elif isinstance(value, bool):
113            # Careful! Check for this before isinstance(int) -- true for bools
114            xml_writer.element(name, str(value).lower())
115        elif (isinstance(value, int) or isinstance(value, long)
116              or isinstance(value, float)):
117            xml_writer.element(name, str(value))
118        elif hasattr(value, 'WriteXml'):
119            # An object that knows how to write itself
120            xml_writer.start(name)
121            value.WriteXml(xml_writer)
122            xml_writer.end()
123        elif isinstance(value, list) or isinstance(value, tuple):
124            # Sequence names are often plural, but the element name must be single
125            if name.endswith('s'):
126                value_element_name = name[0:len(name) - 1]
127            else:
128                value_element_name = name
129            for sequence_value in value:
130                Entity._WriteValue(xml_writer, value_element_name,
131                                   sequence_value)
132        elif hasattr(value, 'iteritems'):  # A mapping type
133            # Map names are often plural, but the element name must be single
134            if name.endswith('s'):
135                map_element_name = name[0:len(name) - 1]
136            else:
137                map_element_name = name
138            Entity._WriteNameValuesXml(xml_writer, map_element_name, value,
139                                       'name', 'value')
140
141    @staticmethod
142    def _WriteNameValuesXml(xml_writer, element_name, name_value_dict,
143                            name_elem, value_elem):
144        """Writes a dict as XML elements with children as keys (names) and values.
145
146    Args:
147      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
148      element_name: name of enclosing element for the name-value pair elements.
149      name_value_dict: the dict to write.
150      name_elem: name of the "name" element.
151      value_elem: name of the "value" element.
152    """
153        if name_value_dict:
154            for name in sorted(
155                    name_value_dict):  # Guarantee order for testability
156                value = name_value_dict[name]
157                xml_writer.start(element_name)
158                Entity._WriteValue(xml_writer, name_elem, name)
159                Entity._WriteValue(xml_writer, value_elem, value)
160                xml_writer.end()
161
162
163class LcovUtils(object):
164    """Just groups Lcov handling."""
165
166    @staticmethod
167    def GetFilename(lcov_section):
168        return lcov_section.split('\n', 1)[0].strip()[3:]
169
170    @staticmethod
171    def LcovSectionToBitFields(lcov_section):
172        """Fill in bit fields that represent covered and instrumented lines.
173
174    Note that lcov line numbers start from 1 while sponge expects line numbers
175    to start from 0, hence the line_num-1 is required.
176
177    Args:
178      lcov_section: string, relevant section of lcov
179
180    Returns:
181      Tuple of google3.testing.coverage.util.bitfield objects. First bitfield
182      represents lines covered. Second bitfield represents total lines
183      instrumented.
184    """
185        covered_bf = bitfield.BitField()
186        instrumented_bf = bitfield.BitField()
187        for line in lcov_section.split('\n'):
188            if line.startswith('DA:'):
189                line_num, times_hit = line.strip()[3:].split(',')
190                instrumented_bf.SetBit(int(line_num) - 1)
191                if times_hit != '0':
192                    covered_bf.SetBit(int(line_num) - 1)
193            elif line.startswith('FN:'):
194                pass  # Function coverage will be supported soon.
195        return covered_bf, instrumented_bf
196
197    @staticmethod
198    def UrlEncode(bit_field):
199        """Convert bit field into url-encoded string of hex representation."""
200        if not bit_field.CountBitsSet():
201            return '%00'
202        else:
203            ret_str = ''
204            for c in bit_field.Get():
205                ret_str += '%%%02x' % ord(c)
206        return ret_str.upper()
207
208    @staticmethod
209    def WriteBitfieldXml(xml_writer, name, value):
210        encoded_value = LcovUtils.UrlEncode(value)
211        xml_writer.element(
212            name, unicode(encoded_value, encoding='latin', errors='replace'))
213
214
215class FileCoverage(Entity):
216    """Represents Sponge FileCoverage.
217
218  instrumented_lines and executed_lines are bit fields with following format:
219  Divide line number by 8 to get index into string.
220  Mod line number by 8 to get bit number (0 = LSB, 7 = MSB).
221
222  Attributes:
223    file_name: name of the file this entry represents.
224    location: the location of the file: PERFORCE, MONDRIAN, UNKNOWN.
225    revision: stores the revision number of the file when location is PERFORCE.
226    instrumented_lines: bitfield of line numbers that have been instrumented
227    executed_lines: bitfield of line numbers that have been executed
228    md5: string. Hex representation of the md5 checksum for the file
229         "file_name". This should only be set if file_name is open in the
230         client.
231    pending_cl: string. CL containing the file "file_name" if it is checked out
232                at the time this invocation is sent out. Should only be set if
233                location is MONDRIAN.
234    sourcerer_depot: string. [optional] The sourcerer depot to use in coverage
235        tab. Only required if your code is stored in one of the PerforceN
236        servers and therefore has it's own Sourcerer instance. For example,
237        Perforce11 code should set sourcerer_depot to "s11".
238  """
239
240    # location
241    PERFORCE = 0
242    MONDRIAN = 1
243    UNKNOWN = 2
244
245    def __init__(self):
246        super(FileCoverage, self).__init__()
247        self.file_name = None
248        self.location = None
249        self.revision = None
250        self.md5 = None
251        self.pending_cl = None
252        self.executed_lines = None
253        self.instrumented_lines = None
254        self.sourcerer_depot = None
255        self._custom_write_attributes = [
256            'executed_lines', 'instrumented_lines'
257        ]
258
259    def WriteXml(self, xml_writer):
260        """Writes this object as XML suitable for Sponge HTTP Redirector.
261
262    Args:
263      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
264    """
265        super(FileCoverage, self).WriteXml(xml_writer)
266        for attr_name in self._custom_write_attributes:
267            value = self.__getattribute__(attr_name)
268            if value:
269                LcovUtils.WriteBitfieldXml(xml_writer, attr_name, value)
270
271    def Combine(self, other_file_coverage):
272        """Combines 2 FileCoverage objects.
273
274    This method expects all fields of the 2 FileCoverage objects to be identical
275    except for the executed_lines and instrumented_lines fields which it will
276    combine into 1 by performing logical OR operation on executed_lines and
277    instrumented_lines bitfields. All other fields are copied directly from
278    source.
279
280    Args:
281      other_file_coverage: FileCoverage object to combine with
282
283    Returns:
284      The combined FileCoverage object
285    """
286        assert self.file_name == other_file_coverage.file_name
287        assert self.location == other_file_coverage.location
288        assert self.revision == other_file_coverage.revision
289        assert self.md5 == other_file_coverage.md5
290        assert self.pending_cl == other_file_coverage.pending_cl
291
292        result_file_coverage = FileCoverage()
293        result_file_coverage.file_name = self.file_name
294        result_file_coverage.location = self.location
295        result_file_coverage.revision = self.revision
296        result_file_coverage.md5 = self.md5
297        result_file_coverage.pending_cl = self.pending_cl
298
299        result_file_coverage.executed_lines = self.executed_lines.Or(
300            other_file_coverage.executed_lines)
301        result_file_coverage.instrumented_lines = self.instrumented_lines.Or(
302            other_file_coverage.instrumented_lines)
303
304        return result_file_coverage
305
306    def FromLcovSection(self, lcov_section):
307        """Fill in coverage from relevant lcov section.
308
309    An lcov section starts with a line starting with 'SF:' followed by filename
310    of covered file and is followed by 1 or more lines of coverage data starting
311    with 'DA:' or 'FN:'.
312
313    'DA:'lines have the format:
314      'DA: line_num, times_covered'
315
316    line_num is the line number of source file starting from 1.
317    times_covered is the number of times the line was covered, starting from 0.
318
319    'FN:' is for function coverage and is not supported yet.
320
321    An example section would look like this:
322      SF:/Volumes/BuildData/PulseData/data/googleclient/picasa4/yt/safe_str.h
323      DA:1412,12
324      DA:1413,12
325      DA:1414,0
326      DA:1415,0
327
328    Args:
329      lcov_section: string, relevant section of lcov file.
330    """
331        if lcov_section:
332            assert lcov_section.startswith('SF:')
333
334            self.file_name = LcovUtils.GetFilename(lcov_section)
335            self.executed_lines, self.instrumented_lines = (
336                LcovUtils.LcovSectionToBitFields(lcov_section))
337
338
339class TargetCodeCoverage(Entity):
340    """Represents Sponge TargetCodeCoverage.
341
342  Attributes:
343    file_coverage: list of FileCoverage object.
344    instrumentation: method of instrumentation: ONTHEFLY, OFFLINE, UNKNOWN
345  """
346
347    # instrumentation
348    ONTHEFLY = 0
349    OFFLINE = 1
350    UNKNOWN = 2
351
352    def __init__(self):
353        super(TargetCodeCoverage, self).__init__()
354        self.file_coverage = []
355        self.instrumentation = None
356
357        # Warning: *DO NOT* switch to Python 2.7 OrderedDict. This code needs to
358        # run on Windows and other environments where Python 2.7 may not be
359        # available.
360        self._file_coverage_map = collections.OrderedDict()
361
362    def FromLcovString(self, lcov_str):
363        """Fill in coverage from lcov-formatted string.
364
365    Args:
366      lcov_str: contents of lcov file as string
367    """
368        for entry in lcov_str.split('end_of_record\n'):
369            file_coverage = FileCoverage()
370            file_coverage.FromLcovSection(entry.strip())
371
372            if not file_coverage.file_name:
373                continue
374
375            prev_file_coverage = self._file_coverage_map.get(
376                file_coverage.file_name)
377            if prev_file_coverage:
378                self._file_coverage_map[file_coverage.file_name] = (
379                    prev_file_coverage.Combine(file_coverage))
380            else:
381                self._file_coverage_map[
382                    file_coverage.file_name] = file_coverage
383
384        self.file_coverage = self._file_coverage_map.values()
385
386    def IndexOf(self, filename):
387        """Index of filename in the FileCoverage map. Must exist!"""
388        return self._file_coverage_map.keys().index(filename)
389
390
391class Sample(Entity):
392    """Represents a single data sample within a Metric object.
393
394  Attributes:
395    value: the data value of this sample -- the thing that we measured.
396    timestamp_in_millis: the time when this particular sample was taken.
397       Milliseconds since the Epoch. Not required, but highly recommended for
398       a proper single-CL view in LoadViz that shows all samples of one run.
399    outcome: SUCCESSFUL_OUTCOME or FAILED_OUTCOME.
400    metadata: a dict of arbitrary user defined name-value pairs.
401      For example, when measuring page load times, one can store the page URL
402      under the key "url" in the metadata.
403  """
404
405    SUCCESSFUL_OUTCOME = 0
406    FAILED_OUTCOME = 1
407
408    def __init__(self):
409        super(Sample, self).__init__()
410        self.value = None
411        self.timestamp_in_millis = None
412        self.outcome = None
413        self.metadata = {}
414
415
416class Percentile(Entity):
417    """Represents a percentile within an Aggregation object.
418
419  Percentile objects only give enough info to filter samples by percentiles,
420  Sponge doesn't store per-percentile means etc.
421
422  Attributes:
423    percentage: upper bracket of the percentile: integer number of percent.
424       Lower bracket is always zero.
425    value: maximum value for the this percentile.
426  """
427
428    def __init__(self):
429        super(Percentile, self).__init__()
430        self.percentage = None
431        self.value = None
432
433
434class Aggregation(Entity):
435    """Represents aggregated values from samples in a Metric object.
436
437  As also noted in Metric, Sponge would compute a default Aggregation
438  if it's not supplied explicitly with a Metric. Sponge currently computes
439  the following percentiles: 50, 80, 90, 95, 99, with no way to control it.
440  If you want other percentiles, you need to provide the Aggregatioin yourself.
441
442  Attributes:
443    count: the number of samples represented by this aggregation.
444    min: minimum sample value.
445    max: maximum sample value.
446    mean: mean of all sample values.
447    standard_deviation: standard deviation of all sample values.
448    percentiles: a sequence of Percentile objects.
449    error_count: the number of samples with error outcomes.
450  """
451
452    def __init__(self):
453        super(Aggregation, self).__init__()
454        self.count = None
455        self.min = None
456        self.max = None
457        self.mean = None
458        self.standard_deviation = None
459        self.error_count = None
460        self.percentiles = []
461
462
463class Metric(Entity):
464    """Represents a single metric under PerformanceData.
465
466  See the comment in PerformanceData about the mapping to sponge.proto.
467
468  Attributes:
469    name: the metric name.
470    time_series: if True, this is a time series, otherwise not a time series.
471    unit: string name of the unit of measure for sample values in this metric.
472    machine_name: hostname where the test was run.
473        If None, use Invocation.hostname.
474    aggregation: an Aggregation object.
475        If None, Sponge will compute it from samples.
476    samples: a sequence of Sample objects.
477  """
478
479    def __init__(self):
480        super(Metric, self).__init__()
481        self.name = None
482        self.time_series = True
483        self.unit = None
484        self.machine_name = None
485        self.aggregation = None
486        self.samples = []
487
488
489class PerformanceData(Entity):
490    """Represents Sponge PerformanceData, only moved under a TargetResult.
491
492  Currently sponge.proto defines PerformanceData as a top level object,
493  stored in a separate table from Invocations. There is an idea to move it
494  under a TargetResult, allowing it to have labels and generally play
495  by the same rules as all other test runs -- coverage etc.
496
497  So far the interim solution is to try to have PerformanceData under
498  a TargetResult only in sponge_client_lite, and do an on the fly
499  conversion to sponge.proto structures in the HTTP Redirector.
500  If all goes well there, then a similar conversion in the other direction
501  (top level PerformanceData -> PerformanceData under a TargetResult)
502  can be implemented in Sponge Java upload code, together with a data model
503  change, allowing backward compatibility with older performance test clients.
504
505  The mapping of the PerformanceData fields missing here is as follows:
506  id -> Invocation.id
507  timestamp_in_millis -> TargetResult.run_date
508  cl -> Invocation.cl
509  config -> TargetResult.configuration_values
510  user -> Invocation.user
511  description, project_name, project_id -- not mapped, if necessary should
512      be added to Invocation and/or TargetResult, as they are not
513      performance-specific. TODO(klm): discuss use cases with havardb@.
514
515  For LoadViz to work properly, Invocation.cl must be supplied even though
516  it's formally optional in the Invocation. It doesn't have to be an actual
517  Perforce CL number, could be an arbitrary string, but these strings must
518  sort in the chronological order -- e.g. may represent a date and time,
519  for example may use an ISO date+time string notation of the run_date.
520
521  Attributes:
522    benchmark: benchmark name -- the most important ID in LoadViz.
523        Must not be None for results to be usable in LoadViz.
524    experiment: experiment name.
525    thread_count: for load tests, the number of concurrent threads.
526    aggregator_strategy: NONE or V1 or V1_NO_DOWNSAMPLE.
527    metrics: a sequence of Metric objects.
528  """
529
530    NONE = 0
531    V1 = 1
532    V1_NO_DOWNSAMPLE = 2
533
534    def __init__(self):
535        super(PerformanceData, self).__init__()
536        self.benchmark = None
537        self.experiment = None
538        self.thread_count = None
539        self.aggregator_strategy = None
540        self.metrics = []
541
542
543class TestFault(Entity):
544    """Test failure/error data.
545
546  Attributes:
547    message: message for the failure/error.
548    exception_type: the type of failure/error.
549    detail: details of the failure/error.
550  """
551
552    def __init__(self):
553        super(TestFault, self).__init__()
554
555        self._permitted_attributes = set(
556            ['message', 'exception_type', 'detail'])
557        self.message = None
558        self.exception_type = None
559        self.detail = None
560
561
562class TestResult(Entity):
563    """Test case data.
564
565  Attributes:
566    child: List of TestResult representing test suites or test cases
567    name: Test result name
568    class_name: Required for test cases, otherwise not
569    was_run: true/false, default true, optional
570    run_duration_millis: -
571    property: List of TestProperty entities.
572    test_case_count: number of test cases
573    failure_count: number of failures
574    error_count: number of errors
575    disabled_count: number of disabled tests
576    test_file_coverage: List of TestCaseFileCoverage
577    test_failure: List of TestFault objects describing test failures
578    test_error: List of TestFault objects describing test errors
579    result: The result of running a test case: COMPLETED, INTERRUPTED, etc
580  """
581
582    # result
583    COMPLETED = 0
584    INTERRUPTED = 1
585    CANCELLED = 2
586    FILTERED = 3
587    SKIPPED = 4
588    SUPPRESSED = 5
589
590    # Match DA lines claiming nonzero execution count.
591    _lcov_executed_re = re.compile(r'^DA:\d+,[1-9][0-9]*', re.MULTILINE)
592
593    def __init__(self):
594        super(TestResult, self).__init__()
595
596        self._permitted_attributes = set([
597            'child', 'name', 'class_name', 'was_run', 'run_duration_millis',
598            'property', 'test_case_count', 'failure_count', 'error_count',
599            'disabled_count', 'test_file_coverage', 'test_failure',
600            'test_error', 'result'
601        ])
602        self.child = []
603        self.name = None
604        self.class_name = None
605        self.was_run = True
606        self.run_duration_millis = None
607        self.property = []
608        self.test_case_count = None
609        self.failure_count = None
610        self.error_count = None
611        self.disabled_count = None
612        self.test_file_coverage = []
613        self.test_error = []
614        self.test_failure = []
615        self.result = None
616
617    def FromLcovString(self, lcov_str, target_code_coverage):
618        """Fill in hit coverage from lcov-formatted string and target_code_coverage.
619
620    Ignores files with zero hit bitmaps; presumes target_code_coverage is final
621    for the purposes of determining the index of filenames.
622
623    Args:
624      lcov_str: contents of lcov file as string
625      target_code_coverage: TargetCodeCoverage for filename indexing
626    """
627        for entry in lcov_str.split('end_of_record\n'):
628
629            if not TestResult._lcov_executed_re.search(entry):
630                continue
631
632            test_file_coverage = TestCaseFileCoverage()
633            test_file_coverage.FromLcovSection(entry.strip(),
634                                               target_code_coverage)
635
636            self.test_file_coverage.append(test_file_coverage)
637
638
639class TestProperty(Entity):
640    """Test property data.
641
642  Attributes:
643    key: A string representing the property key.
644    value: A string representing the property value.
645  """
646
647    def __init__(self):
648        super(TestProperty, self).__init__()
649        self._permitted_attributes = set(['key', 'value'])
650        self.key = None
651        self.value = None
652
653
654class TestCaseFileCoverage(Entity):
655    """Test case file coverage data.
656
657  Attributes:
658    file_coverage_index: index into associated test target's file coverage.
659    executed_lines: bitfield representing executed lines, as for FileCoverage.
660    zipped_executed_lines: zip of executed_lines data, if smaller.
661  """
662
663    def __init__(self):
664        super(TestCaseFileCoverage, self).__init__()
665
666        self._permitted_attributes = set(
667            ['file_coverage_index', 'executed_lines', 'zipped_executed_lines'])
668
669        self.file_coverage_index = None
670        self.executed_lines = 0
671        self.zipped_executed_lines = 0
672        self._custom_write_attributes = [
673            'executed_lines', 'zipped_executed_lines'
674        ]
675
676    def WriteXml(self, xml_writer):
677        """Writes this object as XML suitable for Sponge HTTP Redirector.
678
679    Args:
680      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
681    """
682        super(TestCaseFileCoverage, self).WriteXml(xml_writer)
683        for attr_name in self._custom_write_attributes:
684            value = self.__getattribute__(attr_name)
685            if value:
686                LcovUtils.WriteBitfieldXml(xml_writer, attr_name, value)
687                # TODO(weasel): Mmmaybe lift bitfield handling to the base class.
688
689    def FromLcovSection(self, lcov_section, tcc):
690        if lcov_section:
691            assert lcov_section.startswith('SF:')
692
693            file_name = LcovUtils.GetFilename(lcov_section)
694            self.file_coverage_index = tcc.IndexOf(file_name)
695            self.executed_lines, unused_instrumented_lines = (
696                LcovUtils.LcovSectionToBitFields(lcov_section))
697            # TODO(weasel): compress executed_lines to zipped_* if smaller.
698
699
700class GoogleFilePointer(Entity):
701    """Represents a Google File system path.
702
703  Attributes:
704    name: str name for use by Sponge
705    path: str containing the target Google File.
706    length: integer size of the file; used purely for display purposes.
707  """
708
709    def __init__(self, name, path, length):
710        super(GoogleFilePointer, self).__init__()
711        self.name = name
712        self.path = path
713        self.length = length
714
715    def WriteXml(self, xml_writer):
716        """Writes this object as XML suitable for Sponge HTTP Redirector.
717
718    Args:
719      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
720    """
721        Entity._WriteValue(xml_writer, 'name', self.name)
722        xml_writer.start('google_file_pointer')
723        Entity._WriteValue(xml_writer, 'path', self.path)
724        Entity._WriteValue(xml_writer, 'length', self.length)
725        xml_writer.end()
726
727
728class TargetResult(Entity):
729    """Represents Sponge TargetResult.
730
731  Attributes:
732    index: index of the target result within its parent Invocation.
733        Needed only for update requests, not for initial creation.
734    run_date: execution start timestamp in milliseconds.
735    build_target: the name of the build target that was executed.
736    size: one of size constants: SMALL, MEDIUM, LARGE, OTHER_SIZE, ENORMOUS.
737    environment: how we ran: FORGE, LOCAL_*, OTHER_*, UNKNOWN_*.
738    status: test outcome: PASSED, FAILED, etc.
739    test_result: tree of TestResults representing test suites and test cases.
740    language: programming language of the source code: CC, JAVA, etc.
741    run_duration_millis: execution duration in milliseconds.
742    status_details: a string explaining the status in more detail.
743    attempt_number: for flaky reruns, the number of the run attempt. Start at 1.
744    total_attempts: for flaky reruns, the total number of run attempts.
745    coverage: a TargetCodeCoverage object.
746    performance_data: a PerformanceData object.
747    configuration_values: a dict of test configuration parameters.
748    type: the type of target: TEST, BINARY, LIBRARY, APPLICATION.
749    large_texts: a dict of logs associated with this run. A magic key 'XML Log'
750      allows to upload GUnit/JUnit XML and auto-convert it to TestResults.
751    large_text_pointers: a list of GoogleFilePointers - distinction for
752      formatting only, these are conceptually the same as large_texts.
753  """
754
755    # size - if you update these values ensure to also update the appropriate
756    # enum list in uploader_recommended_options.py
757    SMALL = 0
758    MEDIUM = 1
759    LARGE = 2
760    OTHER_SIZE = 3
761    ENORMOUS = 4
762
763    # environment
764    FORGE = 0
765    LOCAL_PARALLEL = 1
766    LOCAL_SEQUENTIAL = 2
767    OTHER_ENVIRONMENT = 3
768    UNKNOWN_ENVIRONMENT = 4
769
770    # status - if you update these values ensure to also update the appropriate
771    # enum list in uploader_optional_options.py
772    PASSED = 0
773    FAILED = 1
774    CANCELLED_BY_USER = 2
775    ABORTED_BY_TOOL = 3
776    FAILED_TO_BUILD = 4
777    BUILT = 5
778    PENDING = 6
779    UNKNOWN_STATUS = 7
780    INTERNAL_ERROR = 8
781
782    # language - if you update these values ensure to also update the appropriate
783    # enum list in uploader_recommended_options.py
784    UNSPECIFIED_LANGUAGE = 0
785    BORGCFG = 1
786    CC = 2
787    GWT = 3
788    HASKELL = 4
789    JAVA = 5
790    JS = 6
791    PY = 7
792    SH = 8
793    SZL = 9
794
795    # type
796    UNSPECIFIED_TYPE = 0
797    TEST = 1
798    BINARY = 2
799    LIBRARY = 3
800    APPLICATION = 4
801
802    def __init__(self):
803        super(TargetResult, self).__init__()
804        self.index = None
805        self.run_date = long(round(time.time() * 1000))
806        self.build_target = None
807        self.size = None
808        self.environment = None
809        self.status = None
810        self.test_result = None
811        self.language = None
812        self.run_duration_millis = None
813        self.status_details = None
814        self.attempt_number = None
815        self.total_attempts = None
816        self.coverage = None
817        self.performance_data = None
818        self.configuration_values = {}
819        self.type = None
820        self.large_texts = {}
821        self.large_text_pointers = []
822        self._custom_write_attributes = ['large_text_pointers']
823
824    def MarkRunDuration(self):
825        """Assigns run_duration_millis to the current time minus run_date."""
826        assert self.run_date
827        self.run_duration_millis = long(round(
828            time.time() * 1000)) - self.run_date
829        assert self.run_duration_millis > 0
830
831    def WriteXml(self, xml_writer):
832        """Writes this object as XML suitable for Sponge HTTP Redirector.
833
834    Args:
835      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
836    """
837        super(TargetResult, self).WriteXml(xml_writer)
838        # Write out GoogleFilePointers as large_text fields
839        for google_file_pointer in self.large_text_pointers:
840            Entity._WriteValue(xml_writer, 'large_text', google_file_pointer)
841
842
843class Invocation(Entity):
844    """Represents a Sponge Invocation.
845
846  Attributes:
847    id: the ID of an invocation to update.
848        Needed only for update requests, not for initial creation.
849    run_date: execution start timestamp in milliseconds
850    user: username.
851    client: P4 client name.
852    cl: P4 changelist ID.
853    hostname: the host where the tests ran.
854    working_dir: the dir where the tests ran.
855    args: command line arguments of the test command.
856    environment_variables: a dict of notable OS environment variables.
857    configuration_values: a dict of test configuration parameters.
858    large_texts: a dict of logs associated with the entire set of target runs.
859    labels: a list of labels associated with this invocation.
860    target_results: a list of TargetResult objects.
861    large_text_pointers: a list of GoogleFilePointers - distinction for
862      formatting only, these are conceptually the same as large_texts.
863  """
864
865    def __init__(self):
866        super(Invocation, self).__init__()
867        self.id = None
868        self.run_date = long(round(time.time() * 1000))
869        self.user = None
870        self.target_results = []
871        self.client = None
872        self.cl = None
873        self.hostname = socket.gethostname().lower()
874        self.working_dir = os.path.abspath(os.curdir)
875        self.args = None
876        self.environment_variables = {}
877        self.configuration_values = {}
878        self.large_texts = {}
879        self.large_text_pointers = []
880        self.labels = []
881        self._custom_write_attributes = [
882            'environment_variables',
883            'large_text_pointers',
884        ]
885
886    def WriteXml(self, xml_writer):
887        """Writes this object as XML suitable for Sponge HTTP Redirector.
888
889    Args:
890      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
891    """
892        super(Invocation, self).WriteXml(xml_writer)
893        Entity._WriteNameValuesXml(
894            xml_writer,
895            'environment_variable',
896            self.environment_variables,
897            name_elem='variable',
898            value_elem='value')
899        # Write out GoogleFilePointers as large_text fields
900        for google_file_pointer in self.large_text_pointers:
901            Entity._WriteValue(xml_writer, 'large_text', google_file_pointer)
902
903
904# Constants for Uploader.server
905SERVER_PROD = 'backend'
906SERVER_QA = 'backend-qa'
907
908
909class Uploader(Entity):
910    """Uploads Sponge Invocations to the Sponge HTTP Redirector service."""
911
912    def __init__(self,
913                 url_host='sponge-http.appspot.com',
914                 upload_url_path='/create_invocation',
915                 update_url_path='/update_target_result',
916                 server=None):
917        """Initializes the object.
918
919    Args:
920      url_host: host or host:port for the Sponge HTTP Redirector server.
921      upload_url_path: the path after url_host.
922      update_url_path: the path after update_url_host.
923      server: name of the Sponge backend, if None use SERVER_QA.
924    """
925        super(Uploader, self).__init__()
926        self.server = server or SERVER_QA
927        self.invocations = []
928        self._url_host = url_host
929        self._upload_url_path = upload_url_path
930        self._update_url_path = update_url_path
931        self._proxy = None
932        self._https_connection_factory = httplib.HTTPSConnection
933
934    def WriteXml(self, xml_writer):
935        """Writes this object as XML suitable for Sponge HTTP Redirector.
936
937    Args:
938      xml_writer: google3.third_party.python.elementtree.SimpleXMLWriter.
939    """
940        xml_writer.start('xml')
941        super(Uploader, self).WriteXml(xml_writer)
942        xml_writer.end()
943
944    def UseProxy(self, proxy):
945        """Forward requests through a given HTTP proxy.
946
947    Args:
948      proxy: the proxy address as '<host>' or '<host>:<port>'
949    """
950        self._proxy = proxy
951
952    def UseHTTPSConnectionFactory(self, https_connection_factory):
953        """Use the given function to create HTTPS connections.
954
955    This is helpful for clients on later version of Python (2.7.9+) that wish to
956    do client-side SSL authentication via ssl.SSLContext.
957
958    Args:
959      https_connection_factory: A function that takes a string url and returns
960                                an httplib.HTTPSConnection.
961    """
962        self._https_connection_factory = https_connection_factory
963
964    def Upload(self):
965        """Uploads Sponge invocations to the Sponge HTTP Redirector service.
966
967    Returns:
968      A string with Sponge invocation IDs, as returned by the HTTP Redirector.
969
970    Raises:
971      ValueError: when at least one invocation id is not None.
972    """
973        for invocation in self.invocations:
974            if invocation.id:
975                raise ValueError(
976                    'Invocation id must be None for new invocation.')
977        return self._UploadHelper(self._url_host, self._upload_url_path)
978
979    def UploadUpdatedResults(self):
980        """Uploads updated Sponge invocations to the Sponge HTTP Redirector service.
981
982    Returns:
983      A string with Sponge invocation IDs, as returned by the HTTP Redirector.
984
985    Raises:
986      ValueError: when at least one invocation id is None or at least one
987        target result has index of None.
988    """
989        for invocation in self.invocations:
990            if invocation.id is None:
991                raise ValueError('Invocation id must not be None for update.')
992            for target_result in invocation.target_results:
993                if target_result.index is None:
994                    raise ValueError(
995                        'Target result index can not be None for update.')
996        return self._UploadHelper(self._url_host, self._update_url_path)
997
998    def _UploadHelper(self, host, url):
999        """A helper function to perform actual upload of Sponge invocations.
1000
1001    Args:
1002      host: host server to connect to.
1003      url: url for Sponge end point.
1004
1005    Returns:
1006      A string represent Sponge invocation IDs.
1007    """
1008        if self._proxy:
1009            # A simple HTTP proxy request is the same as a regular HTTP request
1010            # via the proxy host:port, except the path after the method (GET or POST)
1011            # is the full actual request URL.
1012            url = 'https://%s%s' % (host, url)
1013            # Assume proxy does not support HTTPS.
1014            http_connect = httplib.HTTPConnection(self._proxy)
1015        else:
1016            http_connect = self._https_connection_factory(host)
1017        xml_str = self.GetXmlString()
1018        http_connect.connect()
1019        http_connect.request('PUT', url, body=xml_str)
1020        response = http_connect.getresponse()
1021        response_str = response.read().strip()
1022        if response_str.startswith('id: "'):
1023            response_str = response_str[5:-1]
1024        return response_str
1025
1026
1027def GetInvocationUrl(server, invocation_id):
1028    if server == 'backend-qa':
1029        return 'http://sponge-qa/%s' % invocation_id
1030    else:
1031        return 'http://tests/%s' % invocation_id
1032