Home | History | Annotate | Download | only in flakiness_dashboard
      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 
      5 #
      6 # Most of this file was ported over from Blink's
      7 # Tools/Scripts/webkitpy/layout_tests/layout_package/json_results_generator.py
      8 # Tools/Scripts/webkitpy/common/net/file_uploader.py
      9 #
     10 
     11 import json
     12 import logging
     13 import mimetypes
     14 import os
     15 import time
     16 import urllib2
     17 
     18 _log = logging.getLogger(__name__)
     19 
     20 _JSON_PREFIX = 'ADD_RESULTS('
     21 _JSON_SUFFIX = ');'
     22 
     23 
     24 def HasJSONWrapper(string):
     25   return string.startswith(_JSON_PREFIX) and string.endswith(_JSON_SUFFIX)
     26 
     27 
     28 def StripJSONWrapper(json_content):
     29   # FIXME: Kill this code once the server returns json instead of jsonp.
     30   if HasJSONWrapper(json_content):
     31     return json_content[len(_JSON_PREFIX):len(json_content) - len(_JSON_SUFFIX)]
     32   return json_content
     33 
     34 
     35 def WriteJSON(json_object, file_path, callback=None):
     36   # Specify separators in order to get compact encoding.
     37   json_string = json.dumps(json_object, separators=(',', ':'))
     38   if callback:
     39     json_string = callback + '(' + json_string + ');'
     40   with open(file_path, 'w') as fp:
     41     fp.write(json_string)
     42 
     43 
     44 def ConvertTrieToFlatPaths(trie, prefix=None):
     45   """Flattens the trie of paths, prepending a prefix to each."""
     46   result = {}
     47   for name, data in trie.iteritems():
     48     if prefix:
     49       name = prefix + '/' + name
     50 
     51     if len(data) and not 'results' in data:
     52       result.update(ConvertTrieToFlatPaths(data, name))
     53     else:
     54       result[name] = data
     55 
     56   return result
     57 
     58 
     59 def AddPathToTrie(path, value, trie):
     60   """Inserts a single path and value into a directory trie structure."""
     61   if not '/' in path:
     62     trie[path] = value
     63     return
     64 
     65   directory, _, rest = path.partition('/')
     66   if not directory in trie:
     67     trie[directory] = {}
     68   AddPathToTrie(rest, value, trie[directory])
     69 
     70 
     71 def TestTimingsTrie(individual_test_timings):
     72   """Breaks a test name into dicts by directory
     73 
     74   foo/bar/baz.html: 1ms
     75   foo/bar/baz1.html: 3ms
     76 
     77   becomes
     78   foo: {
     79       bar: {
     80           baz.html: 1,
     81           baz1.html: 3
     82       }
     83   }
     84   """
     85   trie = {}
     86   for test_result in individual_test_timings:
     87     test = test_result.test_name
     88 
     89     AddPathToTrie(test, int(1000 * test_result.test_run_time), trie)
     90 
     91   return trie
     92 
     93 
     94 class TestResult(object):
     95   """A simple class that represents a single test result."""
     96 
     97   # Test modifier constants.
     98   (NONE, FAILS, FLAKY, DISABLED) = range(4)
     99 
    100   def __init__(self, test, failed=False, elapsed_time=0):
    101     self.test_name = test
    102     self.failed = failed
    103     self.test_run_time = elapsed_time
    104 
    105     test_name = test
    106     try:
    107       test_name = test.split('.')[1]
    108     except IndexError:
    109       _log.warn('Invalid test name: %s.', test)
    110 
    111     if test_name.startswith('FAILS_'):
    112       self.modifier = self.FAILS
    113     elif test_name.startswith('FLAKY_'):
    114       self.modifier = self.FLAKY
    115     elif test_name.startswith('DISABLED_'):
    116       self.modifier = self.DISABLED
    117     else:
    118       self.modifier = self.NONE
    119 
    120   def Fixable(self):
    121     return self.failed or self.modifier == self.DISABLED
    122 
    123 
    124 class JSONResultsGeneratorBase(object):
    125   """A JSON results generator for generic tests."""
    126 
    127   MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG = 750
    128   # Min time (seconds) that will be added to the JSON.
    129   MIN_TIME = 1
    130 
    131   # Note that in non-chromium tests those chars are used to indicate
    132   # test modifiers (FAILS, FLAKY, etc) but not actual test results.
    133   PASS_RESULT = 'P'
    134   SKIP_RESULT = 'X'
    135   FAIL_RESULT = 'F'
    136   FLAKY_RESULT = 'L'
    137   NO_DATA_RESULT = 'N'
    138 
    139   MODIFIER_TO_CHAR = {TestResult.NONE: PASS_RESULT,
    140                       TestResult.DISABLED: SKIP_RESULT,
    141                       TestResult.FAILS: FAIL_RESULT,
    142                       TestResult.FLAKY: FLAKY_RESULT}
    143 
    144   VERSION = 4
    145   VERSION_KEY = 'version'
    146   RESULTS = 'results'
    147   TIMES = 'times'
    148   BUILD_NUMBERS = 'buildNumbers'
    149   TIME = 'secondsSinceEpoch'
    150   TESTS = 'tests'
    151 
    152   FIXABLE_COUNT = 'fixableCount'
    153   FIXABLE = 'fixableCounts'
    154   ALL_FIXABLE_COUNT = 'allFixableCount'
    155 
    156   RESULTS_FILENAME = 'results.json'
    157   TIMES_MS_FILENAME = 'times_ms.json'
    158   INCREMENTAL_RESULTS_FILENAME = 'incremental_results.json'
    159 
    160   # line too long pylint: disable=line-too-long
    161   URL_FOR_TEST_LIST_JSON = (
    162       'http://%s/testfile?builder=%s&name=%s&testlistjson=1&testtype=%s&master=%s')
    163   # pylint: enable=line-too-long
    164 
    165   def __init__(self, builder_name, build_name, build_number,
    166                results_file_base_path, builder_base_url,
    167                test_results_map, svn_repositories=None,
    168                test_results_server=None,
    169                test_type='',
    170                master_name=''):
    171     """Modifies the results.json file. Grabs it off the archive directory
    172     if it is not found locally.
    173 
    174     Args
    175       builder_name: the builder name (e.g. Webkit).
    176       build_name: the build name (e.g. webkit-rel).
    177       build_number: the build number.
    178       results_file_base_path: Absolute path to the directory containing the
    179           results json file.
    180       builder_base_url: the URL where we have the archived test results.
    181           If this is None no archived results will be retrieved.
    182       test_results_map: A dictionary that maps test_name to TestResult.
    183       svn_repositories: A (json_field_name, svn_path) pair for SVN
    184           repositories that tests rely on.  The SVN revision will be
    185           included in the JSON with the given json_field_name.
    186       test_results_server: server that hosts test results json.
    187       test_type: test type string (e.g. 'layout-tests').
    188       master_name: the name of the buildbot master.
    189     """
    190     self._builder_name = builder_name
    191     self._build_name = build_name
    192     self._build_number = build_number
    193     self._builder_base_url = builder_base_url
    194     self._results_directory = results_file_base_path
    195 
    196     self._test_results_map = test_results_map
    197     self._test_results = test_results_map.values()
    198 
    199     self._svn_repositories = svn_repositories
    200     if not self._svn_repositories:
    201       self._svn_repositories = {}
    202 
    203     self._test_results_server = test_results_server
    204     self._test_type = test_type
    205     self._master_name = master_name
    206 
    207     self._archived_results = None
    208 
    209   def GenerateJSONOutput(self):
    210     json_object = self.GetJSON()
    211     if json_object:
    212       file_path = (
    213           os.path.join(
    214               self._results_directory,
    215               self.INCREMENTAL_RESULTS_FILENAME))
    216       WriteJSON(json_object, file_path)
    217 
    218   def GenerateTimesMSFile(self):
    219     times = TestTimingsTrie(self._test_results_map.values())
    220     file_path = os.path.join(self._results_directory, self.TIMES_MS_FILENAME)
    221     WriteJSON(times, file_path)
    222 
    223   def GetJSON(self):
    224     """Gets the results for the results.json file."""
    225     results_json = {}
    226 
    227     if not results_json:
    228       results_json, error = self._GetArchivedJSONResults()
    229       if error:
    230         # If there was an error don't write a results.json
    231         # file at all as it would lose all the information on the
    232         # bot.
    233         _log.error('Archive directory is inaccessible. Not '
    234                    'modifying or clobbering the results.json '
    235                    'file: ' + str(error))
    236         return None
    237 
    238     builder_name = self._builder_name
    239     if results_json and builder_name not in results_json:
    240       _log.debug('Builder name (%s) is not in the results.json file.',
    241                  builder_name)
    242 
    243     self._ConvertJSONToCurrentVersion(results_json)
    244 
    245     if builder_name not in results_json:
    246       results_json[builder_name] = (
    247           self._CreateResultsForBuilderJSON())
    248 
    249     results_for_builder = results_json[builder_name]
    250 
    251     if builder_name:
    252       self._InsertGenericMetaData(results_for_builder)
    253 
    254     self._InsertFailureSummaries(results_for_builder)
    255 
    256     # Update the all failing tests with result type and time.
    257     tests = results_for_builder[self.TESTS]
    258     all_failing_tests = self._GetFailedTestNames()
    259     all_failing_tests.update(ConvertTrieToFlatPaths(tests))
    260 
    261     for test in all_failing_tests:
    262       self._InsertTestTimeAndResult(test, tests)
    263 
    264     return results_json
    265 
    266   def SetArchivedResults(self, archived_results):
    267     self._archived_results = archived_results
    268 
    269   def UploadJSONFiles(self, json_files):
    270     """Uploads the given json_files to the test_results_server (if the
    271     test_results_server is given)."""
    272     if not self._test_results_server:
    273       return
    274 
    275     if not self._master_name:
    276       _log.error(
    277           '--test-results-server was set, but --master-name was not.  Not '
    278           'uploading JSON files.')
    279       return
    280 
    281     _log.info('Uploading JSON files for builder: %s', self._builder_name)
    282     attrs = [('builder', self._builder_name),
    283              ('testtype', self._test_type),
    284              ('master', self._master_name)]
    285 
    286     files = [(json_file, os.path.join(self._results_directory, json_file))
    287              for json_file in json_files]
    288 
    289     url = 'http://%s/testfile/upload' % self._test_results_server
    290     # Set uploading timeout in case appengine server is having problems.
    291     # 120 seconds are more than enough to upload test results.
    292     uploader = _FileUploader(url, 120)
    293     try:
    294       response = uploader.UploadAsMultipartFormData(files, attrs)
    295       if response:
    296         if response.code == 200:
    297           _log.info('JSON uploaded.')
    298         else:
    299           _log.debug(
    300               "JSON upload failed, %d: '%s'", response.code, response.read())
    301       else:
    302         _log.error('JSON upload failed; no response returned')
    303     except Exception, err: # pylint: disable=broad-except
    304       _log.error('Upload failed: %s', err)
    305       return
    306 
    307   def _GetTestTiming(self, test_name):
    308     """Returns test timing data (elapsed time) in second
    309     for the given test_name."""
    310     if test_name in self._test_results_map:
    311       # Floor for now to get time in seconds.
    312       return int(self._test_results_map[test_name].test_run_time)
    313     return 0
    314 
    315   def _GetFailedTestNames(self):
    316     """Returns a set of failed test names."""
    317     return set([r.test_name for r in self._test_results if r.failed])
    318 
    319   def _GetModifierChar(self, test_name):
    320     """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
    321     PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test modifier
    322     for the given test_name.
    323     """
    324     if test_name not in self._test_results_map:
    325       return self.__class__.NO_DATA_RESULT
    326 
    327     test_result = self._test_results_map[test_name]
    328     if test_result.modifier in self.MODIFIER_TO_CHAR.keys():
    329       return self.MODIFIER_TO_CHAR[test_result.modifier]
    330 
    331     return self.__class__.PASS_RESULT
    332 
    333   def _get_result_char(self, test_name):
    334     """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
    335     PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test result
    336     for the given test_name.
    337     """
    338     if test_name not in self._test_results_map:
    339       return self.__class__.NO_DATA_RESULT
    340 
    341     test_result = self._test_results_map[test_name]
    342     if test_result.modifier == TestResult.DISABLED:
    343       return self.__class__.SKIP_RESULT
    344 
    345     if test_result.failed:
    346       return self.__class__.FAIL_RESULT
    347 
    348     return self.__class__.PASS_RESULT
    349 
    350   def _GetSVNRevision(self, in_directory):
    351     """Returns the svn revision for the given directory.
    352 
    353     Args:
    354       in_directory: The directory where svn is to be run.
    355     """
    356     # This is overridden in flakiness_dashboard_results_uploader.py.
    357     raise NotImplementedError()
    358 
    359   def _GetArchivedJSONResults(self):
    360     """Download JSON file that only contains test
    361     name list from test-results server. This is for generating incremental
    362     JSON so the file generated has info for tests that failed before but
    363     pass or are skipped from current run.
    364 
    365     Returns (archived_results, error) tuple where error is None if results
    366     were successfully read.
    367     """
    368     results_json = {}
    369     old_results = None
    370     error = None
    371 
    372     if not self._test_results_server:
    373       return {}, None
    374 
    375     results_file_url = (self.URL_FOR_TEST_LIST_JSON %
    376                         (urllib2.quote(self._test_results_server),
    377                          urllib2.quote(self._builder_name),
    378                          self.RESULTS_FILENAME,
    379                          urllib2.quote(self._test_type),
    380                          urllib2.quote(self._master_name)))
    381 
    382     try:
    383       # FIXME: We should talk to the network via a Host object.
    384       results_file = urllib2.urlopen(results_file_url)
    385       old_results = results_file.read()
    386     except urllib2.HTTPError, http_error:
    387       # A non-4xx status code means the bot is hosed for some reason
    388       # and we can't grab the results.json file off of it.
    389       if http_error.code < 400 and http_error.code >= 500:
    390         error = http_error
    391     except urllib2.URLError, url_error:
    392       error = url_error
    393 
    394     if old_results:
    395       # Strip the prefix and suffix so we can get the actual JSON object.
    396       old_results = StripJSONWrapper(old_results)
    397 
    398       try:
    399         results_json = json.loads(old_results)
    400       except Exception: # pylint: disable=broad-except
    401         _log.debug('results.json was not valid JSON. Clobbering.')
    402         # The JSON file is not valid JSON. Just clobber the results.
    403         results_json = {}
    404     else:
    405       _log.debug('Old JSON results do not exist. Starting fresh.')
    406       results_json = {}
    407 
    408     return results_json, error
    409 
    410   def _InsertFailureSummaries(self, results_for_builder):
    411     """Inserts aggregate pass/failure statistics into the JSON.
    412     This method reads self._test_results and generates
    413     FIXABLE, FIXABLE_COUNT and ALL_FIXABLE_COUNT entries.
    414 
    415     Args:
    416       results_for_builder: Dictionary containing the test results for a
    417           single builder.
    418     """
    419     # Insert the number of tests that failed or skipped.
    420     fixable_count = len([r for r in self._test_results if r.Fixable()])
    421     self._InsertItemIntoRawList(results_for_builder,
    422                                 fixable_count, self.FIXABLE_COUNT)
    423 
    424     # Create a test modifiers (FAILS, FLAKY etc) summary dictionary.
    425     entry = {}
    426     for test_name in self._test_results_map.iterkeys():
    427       result_char = self._GetModifierChar(test_name)
    428       entry[result_char] = entry.get(result_char, 0) + 1
    429 
    430     # Insert the pass/skip/failure summary dictionary.
    431     self._InsertItemIntoRawList(results_for_builder, entry,
    432                                 self.FIXABLE)
    433 
    434     # Insert the number of all the tests that are supposed to pass.
    435     all_test_count = len(self._test_results)
    436     self._InsertItemIntoRawList(results_for_builder,
    437                                 all_test_count, self.ALL_FIXABLE_COUNT)
    438 
    439   def _InsertItemIntoRawList(self, results_for_builder, item, key):
    440     """Inserts the item into the list with the given key in the results for
    441     this builder. Creates the list if no such list exists.
    442 
    443     Args:
    444       results_for_builder: Dictionary containing the test results for a
    445           single builder.
    446       item: Number or string to insert into the list.
    447       key: Key in results_for_builder for the list to insert into.
    448     """
    449     if key in results_for_builder:
    450       raw_list = results_for_builder[key]
    451     else:
    452       raw_list = []
    453 
    454     raw_list.insert(0, item)
    455     raw_list = raw_list[:self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG]
    456     results_for_builder[key] = raw_list
    457 
    458   def _InsertItemRunLengthEncoded(self, item, encoded_results):
    459     """Inserts the item into the run-length encoded results.
    460 
    461     Args:
    462       item: String or number to insert.
    463       encoded_results: run-length encoded results. An array of arrays, e.g.
    464           [[3,'A'],[1,'Q']] encodes AAAQ.
    465     """
    466     if len(encoded_results) and item == encoded_results[0][1]:
    467       num_results = encoded_results[0][0]
    468       if num_results <= self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
    469         encoded_results[0][0] = num_results + 1
    470     else:
    471       # Use a list instead of a class for the run-length encoding since
    472       # we want the serialized form to be concise.
    473       encoded_results.insert(0, [1, item])
    474 
    475   def _InsertGenericMetaData(self, results_for_builder):
    476     """ Inserts generic metadata (such as version number, current time etc)
    477     into the JSON.
    478 
    479     Args:
    480       results_for_builder: Dictionary containing the test results for
    481           a single builder.
    482     """
    483     self._InsertItemIntoRawList(results_for_builder,
    484                                 self._build_number, self.BUILD_NUMBERS)
    485 
    486     # Include SVN revisions for the given repositories.
    487     for (name, path) in self._svn_repositories:
    488       # Note: for JSON file's backward-compatibility we use 'chrome' rather
    489       # than 'chromium' here.
    490       lowercase_name = name.lower()
    491       if lowercase_name == 'chromium':
    492         lowercase_name = 'chrome'
    493       self._InsertItemIntoRawList(results_for_builder,
    494                                   self._GetSVNRevision(path),
    495                                   lowercase_name + 'Revision')
    496 
    497     self._InsertItemIntoRawList(results_for_builder,
    498                                 int(time.time()),
    499                                 self.TIME)
    500 
    501   def _InsertTestTimeAndResult(self, test_name, tests):
    502     """ Insert a test item with its results to the given tests dictionary.
    503 
    504     Args:
    505       tests: Dictionary containing test result entries.
    506     """
    507 
    508     result = self._get_result_char(test_name)
    509     test_time = self._GetTestTiming(test_name)
    510 
    511     this_test = tests
    512     for segment in test_name.split('/'):
    513       if segment not in this_test:
    514         this_test[segment] = {}
    515       this_test = this_test[segment]
    516 
    517     if not len(this_test):
    518       self._PopulateResultsAndTimesJSON(this_test)
    519 
    520     if self.RESULTS in this_test:
    521       self._InsertItemRunLengthEncoded(result, this_test[self.RESULTS])
    522     else:
    523       this_test[self.RESULTS] = [[1, result]]
    524 
    525     if self.TIMES in this_test:
    526       self._InsertItemRunLengthEncoded(test_time, this_test[self.TIMES])
    527     else:
    528       this_test[self.TIMES] = [[1, test_time]]
    529 
    530   def _ConvertJSONToCurrentVersion(self, results_json):
    531     """If the JSON does not match the current version, converts it to the
    532     current version and adds in the new version number.
    533     """
    534     if self.VERSION_KEY in results_json:
    535       archive_version = results_json[self.VERSION_KEY]
    536       if archive_version == self.VERSION:
    537         return
    538     else:
    539       archive_version = 3
    540 
    541     # version 3->4
    542     if archive_version == 3:
    543       for results in results_json.values():
    544         self._ConvertTestsToTrie(results)
    545 
    546     results_json[self.VERSION_KEY] = self.VERSION
    547 
    548   def _ConvertTestsToTrie(self, results):
    549     if not self.TESTS in results:
    550       return
    551 
    552     test_results = results[self.TESTS]
    553     test_results_trie = {}
    554     for test in test_results.iterkeys():
    555       single_test_result = test_results[test]
    556       AddPathToTrie(test, single_test_result, test_results_trie)
    557 
    558     results[self.TESTS] = test_results_trie
    559 
    560   def _PopulateResultsAndTimesJSON(self, results_and_times):
    561     results_and_times[self.RESULTS] = []
    562     results_and_times[self.TIMES] = []
    563     return results_and_times
    564 
    565   def _CreateResultsForBuilderJSON(self):
    566     results_for_builder = {}
    567     results_for_builder[self.TESTS] = {}
    568     return results_for_builder
    569 
    570   def _RemoveItemsOverMaxNumberOfBuilds(self, encoded_list):
    571     """Removes items from the run-length encoded list after the final
    572     item that exceeds the max number of builds to track.
    573 
    574     Args:
    575       encoded_results: run-length encoded results. An array of arrays, e.g.
    576           [[3,'A'],[1,'Q']] encodes AAAQ.
    577     """
    578     num_builds = 0
    579     index = 0
    580     for result in encoded_list:
    581       num_builds = num_builds + result[0]
    582       index = index + 1
    583       if num_builds > self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
    584         return encoded_list[:index]
    585     return encoded_list
    586 
    587   def _NormalizeResultsJSON(self, test, test_name, tests):
    588     """ Prune tests where all runs pass or tests that no longer exist and
    589     truncate all results to maxNumberOfBuilds.
    590 
    591     Args:
    592       test: ResultsAndTimes object for this test.
    593       test_name: Name of the test.
    594       tests: The JSON object with all the test results for this builder.
    595     """
    596     test[self.RESULTS] = self._RemoveItemsOverMaxNumberOfBuilds(
    597         test[self.RESULTS])
    598     test[self.TIMES] = self._RemoveItemsOverMaxNumberOfBuilds(
    599         test[self.TIMES])
    600 
    601     is_all_pass = self._IsResultsAllOfType(test[self.RESULTS],
    602                                            self.PASS_RESULT)
    603     is_all_no_data = self._IsResultsAllOfType(test[self.RESULTS],
    604                                               self.NO_DATA_RESULT)
    605     max_time = max([test_time[1] for test_time in test[self.TIMES]])
    606 
    607     # Remove all passes/no-data from the results to reduce noise and
    608     # filesize. If a test passes every run, but takes > MIN_TIME to run,
    609     # don't throw away the data.
    610     if is_all_no_data or (is_all_pass and max_time <= self.MIN_TIME):
    611       del tests[test_name]
    612 
    613   # method could be a function pylint: disable=R0201
    614   def _IsResultsAllOfType(self, results, result_type):
    615     """Returns whether all the results are of the given type
    616     (e.g. all passes)."""
    617     return len(results) == 1 and results[0][1] == result_type
    618 
    619 
    620 class _FileUploader(object):
    621 
    622   def __init__(self, url, timeout_seconds):
    623     self._url = url
    624     self._timeout_seconds = timeout_seconds
    625 
    626   def UploadAsMultipartFormData(self, files, attrs):
    627     file_objs = []
    628     for filename, path in files:
    629       with file(path, 'rb') as fp:
    630         file_objs.append(('file', filename, fp.read()))
    631 
    632     # FIXME: We should use the same variable names for the formal and actual
    633     # parameters.
    634     content_type, data = _EncodeMultipartFormData(attrs, file_objs)
    635     return self._UploadData(content_type, data)
    636 
    637   def _UploadData(self, content_type, data):
    638     start = time.time()
    639     end = start + self._timeout_seconds
    640     while time.time() < end:
    641       try:
    642         request = urllib2.Request(self._url, data,
    643                                   {'Content-Type': content_type})
    644         return urllib2.urlopen(request)
    645       except urllib2.HTTPError as e:
    646         _log.warn("Received HTTP status %s loading \"%s\".  "
    647                   'Retrying in 10 seconds...', e.code, e.filename)
    648         time.sleep(10)
    649 
    650 
    651 def _GetMIMEType(filename):
    652   return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
    653 
    654 
    655 # FIXME: Rather than taking tuples, this function should take more
    656 # structured data.
    657 def _EncodeMultipartFormData(fields, files):
    658   """Encode form fields for multipart/form-data.
    659 
    660   Args:
    661     fields: A sequence of (name, value) elements for regular form fields.
    662     files: A sequence of (name, filename, value) elements for data to be
    663            uploaded as files.
    664   Returns:
    665     (content_type, body) ready for httplib.HTTP instance.
    666 
    667   Source:
    668     http://code.google.com/p/rietveld/source/browse/trunk/upload.py
    669   """
    670   BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
    671   CRLF = '\r\n'
    672   lines = []
    673 
    674   for key, value in fields:
    675     lines.append('--' + BOUNDARY)
    676     lines.append('Content-Disposition: form-data; name="%s"' % key)
    677     lines.append('')
    678     if isinstance(value, unicode):
    679       value = value.encode('utf-8')
    680     lines.append(value)
    681 
    682   for key, filename, value in files:
    683     lines.append('--' + BOUNDARY)
    684     lines.append('Content-Disposition: form-data; name="%s"; '
    685                  'filename="%s"' % (key, filename))
    686     lines.append('Content-Type: %s' % _GetMIMEType(filename))
    687     lines.append('')
    688     if isinstance(value, unicode):
    689       value = value.encode('utf-8')
    690     lines.append(value)
    691 
    692   lines.append('--' + BOUNDARY + '--')
    693   lines.append('')
    694   body = CRLF.join(lines)
    695   content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
    696   return content_type, body
    697