blob: 6d429dfa1c1ac4116763e36b75641bede6189b45 [file] [log] [blame]
rmistry@google.com8e3ff8c2013-01-17 12:55:34 +00001# Copyright (c) 2013 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"""Top-level presubmit script for Skia.
7
8See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
9for more details about the presubmit API built into gcl.
10"""
11
rmistry3cfd1ad2015-03-25 12:53:35 -070012import csv
commit-bot@chromium.org745e08c2014-02-03 14:18:32 +000013import fnmatch
rmistry@google.comf6c5f752013-03-29 17:26:00 +000014import os
commit-bot@chromium.orgcfdc5962014-01-31 17:33:04 +000015import re
rmistryd223fb22015-02-26 10:16:13 -080016import subprocess
rmistry@google.comf6c5f752013-03-29 17:26:00 +000017import sys
commit-bot@chromium.org745e08c2014-02-03 14:18:32 +000018import traceback
rmistry@google.comf6c5f752013-03-29 17:26:00 +000019
rmistry@google.comc2993442013-01-23 14:35:58 +000020
commit-bot@chromium.orgcfdc5962014-01-31 17:33:04 +000021REVERT_CL_SUBJECT_PREFIX = 'Revert '
22
rmistry@google.com547012d2013-04-12 19:45:46 +000023SKIA_TREE_STATUS_URL = 'http://skia-tree-status.appspot.com'
24
rmistry3cfd1ad2015-03-25 12:53:35 -070025CQ_KEYWORDS_THAT_NEED_APPENDING = ('CQ_INCLUDE_TRYBOTS', 'CQ_EXTRA_TRYBOTS',
26 'CQ_EXCLUDE_TRYBOTS', 'CQ_TRYBOTS')
27
rmistryf2d83ca2014-08-26 10:30:29 -070028# Please add the complete email address here (and not just 'xyz@' or 'xyz').
rmistry@google.comfb4a68d2013-08-12 14:51:20 +000029PUBLIC_API_OWNERS = (
30 'reed@chromium.org',
31 'reed@google.com',
32 'bsalomon@chromium.org',
33 'bsalomon@google.com',
rmistry83fab472014-07-18 05:25:56 -070034 'djsollen@chromium.org',
35 'djsollen@google.com',
rmistry@google.comfb4a68d2013-08-12 14:51:20 +000036)
37
commit-bot@chromium.org745e08c2014-02-03 14:18:32 +000038AUTHORS_FILE_NAME = 'AUTHORS'
39
rmistryd223fb22015-02-26 10:16:13 -080040DOCS_PREVIEW_URL = 'https://skia.org/?cl='
41
rmistry@google.com547012d2013-04-12 19:45:46 +000042
rmistry@google.com713276b2013-01-25 18:27:34 +000043def _CheckChangeHasEol(input_api, output_api, source_file_filter=None):
44 """Checks that files end with atleast one \n (LF)."""
45 eof_files = []
46 for f in input_api.AffectedSourceFiles(source_file_filter):
47 contents = input_api.ReadFile(f, 'rb')
48 # Check that the file ends in atleast one newline character.
49 if len(contents) > 1 and contents[-1:] != '\n':
50 eof_files.append(f.LocalPath())
51
52 if eof_files:
53 return [output_api.PresubmitPromptWarning(
54 'These files should end in a newline character:',
55 items=eof_files)]
56 return []
57
58
Eric Borenbb0ef0a2014-06-25 11:13:27 -040059def _PythonChecks(input_api, output_api):
60 """Run checks on any modified Python files."""
61 pylint_disabled_warnings = (
62 'F0401', # Unable to import.
63 'E0611', # No name in module.
64 'W0232', # Class has no __init__ method.
65 'E1002', # Use of super on an old style class.
66 'W0403', # Relative import used.
67 'R0201', # Method could be a function.
68 'E1003', # Using class name in super.
69 'W0613', # Unused argument.
70 )
71 # Run Pylint on only the modified python files. Unfortunately it still runs
72 # Pylint on the whole file instead of just the modified lines.
73 affected_python_files = []
74 for affected_file in input_api.AffectedSourceFiles(None):
75 affected_file_path = affected_file.LocalPath()
76 if affected_file_path.endswith('.py'):
77 affected_python_files.append(affected_file_path)
78 return input_api.canned_checks.RunPylint(
79 input_api, output_api,
80 disabled_warnings=pylint_disabled_warnings,
81 white_list=affected_python_files)
82
83
rmistry01cbf6c2015-03-12 07:48:40 -070084def _IfDefChecks(input_api, output_api):
85 """Ensures if/ifdef are not before includes. See skbug/3362 for details."""
86 comment_block_start_pattern = re.compile('^\s*\/\*.*$')
87 comment_block_middle_pattern = re.compile('^\s+\*.*')
88 comment_block_end_pattern = re.compile('^\s+\*\/.*$')
89 single_line_comment_pattern = re.compile('^\s*//.*$')
90 def is_comment(line):
91 return (comment_block_start_pattern.match(line) or
92 comment_block_middle_pattern.match(line) or
93 comment_block_end_pattern.match(line) or
94 single_line_comment_pattern.match(line))
95
96 empty_line_pattern = re.compile('^\s*$')
97 def is_empty_line(line):
98 return empty_line_pattern.match(line)
99
100 failing_files = []
101 for affected_file in input_api.AffectedSourceFiles(None):
102 affected_file_path = affected_file.LocalPath()
103 if affected_file_path.endswith('.cpp') or affected_file_path.endswith('.h'):
104 f = open(affected_file_path)
105 for line in f.xreadlines():
106 if is_comment(line) or is_empty_line(line):
107 continue
108 # The below will be the first real line after comments and newlines.
109 if line.startswith('#if 0 '):
110 pass
111 elif line.startswith('#if ') or line.startswith('#ifdef '):
112 failing_files.append(affected_file_path)
113 break
114
115 results = []
116 if failing_files:
117 results.append(
118 output_api.PresubmitError(
119 'The following files have #if or #ifdef before includes:\n%s\n\n'
120 'See skbug.com/3362 for why this should be fixed.' %
121 '\n'.join(failing_files)))
122 return results
123
124
borenetc7c91802015-03-25 04:47:02 -0700125def _CopyrightChecks(input_api, output_api, source_file_filter=None):
126 results = []
127 year_pattern = r'\d{4}'
128 year_range_pattern = r'%s(-%s)?' % (year_pattern, year_pattern)
129 years_pattern = r'%s(,%s)*,?' % (year_range_pattern, year_range_pattern)
130 copyright_pattern = (
131 r'Copyright (\([cC]\) )?%s \w+' % years_pattern)
132
133 for affected_file in input_api.AffectedSourceFiles(source_file_filter):
134 if 'third_party' in affected_file.LocalPath():
135 continue
136 contents = input_api.ReadFile(affected_file, 'rb')
137 if not re.search(copyright_pattern, contents):
138 results.append(output_api.PresubmitError(
139 '%s is missing a correct copyright header.' % affected_file))
140 return results
141
142
mtkleine4b19c42015-05-05 10:28:44 -0700143def _ToolFlags(input_api, output_api):
144 """Make sure `{dm,nanobench}_flags.py test` passes if modified."""
145 results = []
146 sources = lambda x: ('dm_flags.py' in x.LocalPath() or
147 'nanobench_flags.py' in x.LocalPath())
148 for f in input_api.AffectedSourceFiles(sources):
149 if 0 != subprocess.call(['python', f.LocalPath(), 'test']):
150 results.append(output_api.PresubmitError('`python %s test` failed' % f))
151 return results
152
153
rmistry@google.com6be0b4c2013-01-17 14:50:59 +0000154def _CommonChecks(input_api, output_api):
155 """Presubmit checks common to upload and commit."""
156 results = []
157 sources = lambda x: (x.LocalPath().endswith('.h') or
158 x.LocalPath().endswith('.gypi') or
159 x.LocalPath().endswith('.gyp') or
160 x.LocalPath().endswith('.py') or
161 x.LocalPath().endswith('.sh') or
mtklein18e55802015-03-25 07:21:20 -0700162 x.LocalPath().endswith('.m') or
163 x.LocalPath().endswith('.mm') or
164 x.LocalPath().endswith('.go') or
165 x.LocalPath().endswith('.c') or
166 x.LocalPath().endswith('.cc') or
rmistry@google.com6be0b4c2013-01-17 14:50:59 +0000167 x.LocalPath().endswith('.cpp'))
168 results.extend(
rmistry@google.com713276b2013-01-25 18:27:34 +0000169 _CheckChangeHasEol(
rmistry@google.com6be0b4c2013-01-17 14:50:59 +0000170 input_api, output_api, source_file_filter=sources))
Eric Borenbb0ef0a2014-06-25 11:13:27 -0400171 results.extend(_PythonChecks(input_api, output_api))
rmistry01cbf6c2015-03-12 07:48:40 -0700172 results.extend(_IfDefChecks(input_api, output_api))
borenetc7c91802015-03-25 04:47:02 -0700173 results.extend(_CopyrightChecks(input_api, output_api,
174 source_file_filter=sources))
mtkleine4b19c42015-05-05 10:28:44 -0700175 results.extend(_ToolFlags(input_api, output_api))
rmistry@google.com6be0b4c2013-01-17 14:50:59 +0000176 return results
177
rmistry@google.com8e3ff8c2013-01-17 12:55:34 +0000178
179def CheckChangeOnUpload(input_api, output_api):
rmistry@google.com6be0b4c2013-01-17 14:50:59 +0000180 """Presubmit checks for the change on upload.
181
182 The following are the presubmit checks:
183 * Check change has one and only one EOL.
184 """
185 results = []
186 results.extend(_CommonChecks(input_api, output_api))
187 return results
rmistry@google.com8e3ff8c2013-01-17 12:55:34 +0000188
189
rmistry@google.comc2993442013-01-23 14:35:58 +0000190def _CheckTreeStatus(input_api, output_api, json_url):
191 """Check whether to allow commit.
192
193 Args:
194 input_api: input related apis.
195 output_api: output related apis.
196 json_url: url to download json style status.
197 """
198 tree_status_results = input_api.canned_checks.CheckTreeIsOpen(
199 input_api, output_api, json_url=json_url)
200 if not tree_status_results:
201 # Check for caution state only if tree is not closed.
202 connection = input_api.urllib2.urlopen(json_url)
203 status = input_api.json.loads(connection.read())
204 connection.close()
rmistry@google.comf6c5f752013-03-29 17:26:00 +0000205 if ('caution' in status['message'].lower() and
206 os.isatty(sys.stdout.fileno())):
207 # Display a prompt only if we are in an interactive shell. Without this
208 # check the commit queue behaves incorrectly because it considers
209 # prompts to be failures.
rmistry@google.comc2993442013-01-23 14:35:58 +0000210 short_text = 'Tree state is: ' + status['general_state']
211 long_text = status['message'] + '\n' + json_url
212 tree_status_results.append(
213 output_api.PresubmitPromptWarning(
214 message=short_text, long_text=long_text))
rmistry@google.com547012d2013-04-12 19:45:46 +0000215 else:
216 # Tree status is closed. Put in message about contacting sheriff.
217 connection = input_api.urllib2.urlopen(
218 SKIA_TREE_STATUS_URL + '/current-sheriff')
219 sheriff_details = input_api.json.loads(connection.read())
220 if sheriff_details:
221 tree_status_results[0]._message += (
222 '\n\nPlease contact the current Skia sheriff (%s) if you are trying '
223 'to submit a build fix\nand do not know how to submit because the '
224 'tree is closed') % sheriff_details['username']
rmistry@google.comc2993442013-01-23 14:35:58 +0000225 return tree_status_results
226
227
commit-bot@chromium.org745e08c2014-02-03 14:18:32 +0000228def _CheckOwnerIsInAuthorsFile(input_api, output_api):
229 results = []
230 issue = input_api.change.issue
231 if issue and input_api.rietveld:
rmistry83fab472014-07-18 05:25:56 -0700232 issue_properties = input_api.rietveld.get_issue_properties(
commit-bot@chromium.org745e08c2014-02-03 14:18:32 +0000233 issue=int(issue), messages=False)
234 owner_email = issue_properties['owner_email']
235
236 try:
237 authors_content = ''
238 for line in open(AUTHORS_FILE_NAME):
239 if not line.startswith('#'):
240 authors_content += line
241 email_fnmatches = re.findall('<(.*)>', authors_content)
242 for email_fnmatch in email_fnmatches:
243 if fnmatch.fnmatch(owner_email, email_fnmatch):
244 # Found a match, the user is in the AUTHORS file break out of the loop
245 break
246 else:
247 # TODO(rmistry): Remove the below CLA messaging once a CLA checker has
248 # been added to the CQ.
249 results.append(
250 output_api.PresubmitError(
251 'The email %s is not in Skia\'s AUTHORS file.\n'
252 'Issue owner, this CL must include an addition to the Skia AUTHORS '
253 'file.\n'
254 'Googler reviewers, please check that the AUTHORS entry '
255 'corresponds to an email address in http://goto/cla-signers. If it '
256 'does not then ask the issue owner to sign the CLA at '
257 'https://developers.google.com/open-source/cla/individual '
258 '(individual) or '
259 'https://developers.google.com/open-source/cla/corporate '
260 '(corporate).'
rmistry83fab472014-07-18 05:25:56 -0700261 % owner_email))
commit-bot@chromium.org745e08c2014-02-03 14:18:32 +0000262 except IOError:
263 # Do not fail if authors file cannot be found.
264 traceback.print_exc()
265 input_api.logging.error('AUTHORS file not found!')
266
267 return results
268
269
rmistry@google.comfb4a68d2013-08-12 14:51:20 +0000270def _CheckLGTMsForPublicAPI(input_api, output_api):
271 """Check LGTMs for public API changes.
272
273 For public API files make sure there is an LGTM from the list of owners in
274 PUBLIC_API_OWNERS.
275 """
276 results = []
277 requires_owner_check = False
rmistry9407ece2014-08-26 14:00:54 -0700278 for affected_file in input_api.AffectedFiles():
279 affected_file_path = affected_file.LocalPath()
rmistry@google.comfb4a68d2013-08-12 14:51:20 +0000280 file_path, file_ext = os.path.splitext(affected_file_path)
rmistry9407ece2014-08-26 14:00:54 -0700281 # We only care about files that end in .h and are under the top-level
282 # include dir.
283 if file_ext == '.h' and 'include' == file_path.split(os.path.sep)[0]:
rmistry@google.comfb4a68d2013-08-12 14:51:20 +0000284 requires_owner_check = True
285
286 if not requires_owner_check:
287 return results
288
289 lgtm_from_owner = False
290 issue = input_api.change.issue
291 if issue and input_api.rietveld:
292 issue_properties = input_api.rietveld.get_issue_properties(
293 issue=int(issue), messages=True)
commit-bot@chromium.orgcfdc5962014-01-31 17:33:04 +0000294 if re.match(REVERT_CL_SUBJECT_PREFIX, issue_properties['subject'], re.I):
295 # It is a revert CL, ignore the public api owners check.
296 return results
rmistryf2d83ca2014-08-26 10:30:29 -0700297
rmistry04abf132015-04-07 07:41:51 -0700298 # TODO(rmistry): Stop checking for COMMIT=false once crbug/470609 is
299 # resolved.
300 if issue_properties['cq_dry_run'] or re.search(
301 r'^COMMIT=false$', issue_properties['description'], re.M):
302 # Ignore public api owners check for dry run CLs since they are not
rmistryf91b7172015-03-12 09:48:10 -0700303 # going to be committed.
304 return results
305
rmistryf2d83ca2014-08-26 10:30:29 -0700306 match = re.search(r'^TBR=(.*)$', issue_properties['description'], re.M)
307 if match:
308 tbr_entries = match.group(1).strip().split(',')
309 for owner in PUBLIC_API_OWNERS:
310 if owner in tbr_entries or owner.split('@')[0] in tbr_entries:
311 # If an owner is specified in the TBR= line then ignore the public
312 # api owners check.
313 return results
314
rmistry@google.comfb4a68d2013-08-12 14:51:20 +0000315 if issue_properties['owner_email'] in PUBLIC_API_OWNERS:
316 # An owner created the CL that is an automatic LGTM.
317 lgtm_from_owner = True
318
319 messages = issue_properties.get('messages')
320 if messages:
321 for message in messages:
322 if (message['sender'] in PUBLIC_API_OWNERS and
323 'lgtm' in message['text'].lower()):
324 # Found an lgtm in a message from an owner.
325 lgtm_from_owner = True
Eric Borenbb0ef0a2014-06-25 11:13:27 -0400326 break
commit-bot@chromium.orgcfdc5962014-01-31 17:33:04 +0000327
rmistry@google.comfb4a68d2013-08-12 14:51:20 +0000328 if not lgtm_from_owner:
329 results.append(
330 output_api.PresubmitError(
331 'Since the CL is editing public API, you must have an LGTM from '
332 'one of: %s' % str(PUBLIC_API_OWNERS)))
333 return results
334
335
rmistryd223fb22015-02-26 10:16:13 -0800336def PostUploadHook(cl, change, output_api):
337 """git cl upload will call this hook after the issue is created/modified.
338
339 This hook does the following:
340 * Adds a link to preview docs changes if there are any docs changes in the CL.
341 * Adds 'NOTRY=true' if the CL contains only docs changes.
rmistry896f3932015-02-26 11:52:05 -0800342 * Adds 'NOTREECHECKS=true' for non master branch changes since they do not
343 need to be gated on the master branch's tree.
344 * Adds 'NOTRY=true' for non master branch changes since trybots do not yet
345 work on them.
rmistryd223fb22015-02-26 10:16:13 -0800346 """
347
348 results = []
349 atleast_one_docs_change = False
350 all_docs_changes = True
351 for affected_file in change.AffectedFiles():
352 affected_file_path = affected_file.LocalPath()
353 file_path, _ = os.path.splitext(affected_file_path)
354 if 'site' == file_path.split(os.path.sep)[0]:
355 atleast_one_docs_change = True
356 else:
357 all_docs_changes = False
358 if atleast_one_docs_change and not all_docs_changes:
359 break
360
361 issue = cl.issue
362 rietveld_obj = cl.RpcServer()
363 if issue and rietveld_obj:
364 original_description = rietveld_obj.get_description(issue)
365 new_description = original_description
366
367 # If the change includes only doc changes then add NOTRY=true in the
368 # CL's description if it does not exist yet.
369 if all_docs_changes and not re.search(
rmistry896f3932015-02-26 11:52:05 -0800370 r'^NOTRY=true$', new_description, re.M | re.I):
rmistryd223fb22015-02-26 10:16:13 -0800371 new_description += '\nNOTRY=true'
372 results.append(
373 output_api.PresubmitNotifyResult(
374 'This change has only doc changes. Automatically added '
375 '\'NOTRY=true\' to the CL\'s description'))
376
377 # If there is atleast one docs change then add preview link in the CL's
378 # description if it does not already exist there.
379 if atleast_one_docs_change and not re.search(
rmistry896f3932015-02-26 11:52:05 -0800380 r'^DOCS_PREVIEW=.*', new_description, re.M | re.I):
rmistryd223fb22015-02-26 10:16:13 -0800381 # Automatically add a link to where the docs can be previewed.
382 new_description += '\nDOCS_PREVIEW= %s%s' % (DOCS_PREVIEW_URL, issue)
383 results.append(
384 output_api.PresubmitNotifyResult(
385 'Automatically added a link to preview the docs changes to the '
386 'CL\'s description'))
387
rmistry896f3932015-02-26 11:52:05 -0800388 # If the target ref is not master then add NOTREECHECKS=true and NOTRY=true
389 # to the CL's description if it does not already exist there.
390 target_ref = rietveld_obj.get_issue_properties(issue, False).get(
391 'target_ref', '')
392 if target_ref != 'refs/heads/master':
393 if not re.search(
394 r'^NOTREECHECKS=true$', new_description, re.M | re.I):
395 new_description += "\nNOTREECHECKS=true"
396 results.append(
397 output_api.PresubmitNotifyResult(
398 'Branch changes do not need to rely on the master branch\'s '
399 'tree status. Automatically added \'NOTREECHECKS=true\' to the '
400 'CL\'s description'))
401 if not re.search(
402 r'^NOTRY=true$', new_description, re.M | re.I):
403 new_description += "\nNOTRY=true"
404 results.append(
405 output_api.PresubmitNotifyResult(
406 'Trybots do not yet work for non-master branches. '
407 'Automatically added \'NOTRY=true\' to the CL\'s description'))
408
rmistry3cfd1ad2015-03-25 12:53:35 -0700409 # Read and process the HASHTAGS file.
rmistry57291dc2015-04-01 09:12:51 -0700410 hashtags_fullpath = os.path.join(change._local_root, 'HASHTAGS')
411 with open(hashtags_fullpath, 'rb') as hashtags_csv:
rmistry3cfd1ad2015-03-25 12:53:35 -0700412 hashtags_reader = csv.reader(hashtags_csv, delimiter=',')
413 for row in hashtags_reader:
414 if not row or row[0].startswith('#'):
415 # Ignore empty lines and comments
416 continue
417 hashtag = row[0]
418 # Search for the hashtag in the description.
419 if re.search('#%s' % hashtag, new_description, re.M | re.I):
420 for mapped_text in row[1:]:
421 # Special case handling for CQ_KEYWORDS_THAT_NEED_APPENDING.
422 appended_description = _HandleAppendingCQKeywords(
423 hashtag, mapped_text, new_description, results, output_api)
424 if appended_description:
425 new_description = appended_description
426 continue
427
428 # Add the mapped text if it does not already exist in the
429 # CL's description.
430 if not re.search(
431 r'^%s$' % mapped_text, new_description, re.M | re.I):
432 new_description += '\n%s' % mapped_text
433 results.append(
434 output_api.PresubmitNotifyResult(
435 'Found \'#%s\', automatically added \'%s\' to the CL\'s '
436 'description' % (hashtag, mapped_text)))
rmistry896f3932015-02-26 11:52:05 -0800437
rmistryd223fb22015-02-26 10:16:13 -0800438 # If the description has changed update it.
439 if new_description != original_description:
440 rietveld_obj.update_description(issue, new_description)
441
442 return results
443
444
rmistry3cfd1ad2015-03-25 12:53:35 -0700445def _HandleAppendingCQKeywords(hashtag, keyword_and_value, description,
446 results, output_api):
447 """Handles the CQ keywords that need appending if specified in hashtags."""
448 keyword = keyword_and_value.split('=')[0]
449 if keyword in CQ_KEYWORDS_THAT_NEED_APPENDING:
450 # If the keyword is already in the description then append to it.
451 match = re.search(
452 r'^%s=(.*)$' % keyword, description, re.M | re.I)
453 if match:
454 old_values = match.group(1).split(';')
455 new_value = keyword_and_value.split('=')[1]
456 if new_value in old_values:
457 # Do not need to do anything here.
458 return description
459 # Update the description with the new values.
460 new_description = description.replace(
461 match.group(0), "%s;%s" % (match.group(0), new_value))
462 results.append(
463 output_api.PresubmitNotifyResult(
464 'Found \'#%s\', automatically appended \'%s\' to %s in '
465 'the CL\'s description' % (hashtag, new_value, keyword)))
466 return new_description
467 return None
468
469
rmistry@google.com8e3ff8c2013-01-17 12:55:34 +0000470def CheckChangeOnCommit(input_api, output_api):
471 """Presubmit checks for the change on commit.
472
473 The following are the presubmit checks:
rmistry@google.com6be0b4c2013-01-17 14:50:59 +0000474 * Check change has one and only one EOL.
rmistry@google.comc2993442013-01-23 14:35:58 +0000475 * Ensures that the Skia tree is open in
476 http://skia-tree-status.appspot.com/. Shows a warning if it is in 'Caution'
477 state and an error if it is in 'Closed' state.
rmistry@google.com8e3ff8c2013-01-17 12:55:34 +0000478 """
479 results = []
rmistry@google.com6be0b4c2013-01-17 14:50:59 +0000480 results.extend(_CommonChecks(input_api, output_api))
rmistry@google.com8e3ff8c2013-01-17 12:55:34 +0000481 results.extend(
rmistry@google.comc2993442013-01-23 14:35:58 +0000482 _CheckTreeStatus(input_api, output_api, json_url=(
rmistry@google.com547012d2013-04-12 19:45:46 +0000483 SKIA_TREE_STATUS_URL + '/banner-status?format=json')))
rmistry@google.comfb4a68d2013-08-12 14:51:20 +0000484 results.extend(_CheckLGTMsForPublicAPI(input_api, output_api))
commit-bot@chromium.org745e08c2014-02-03 14:18:32 +0000485 results.extend(_CheckOwnerIsInAuthorsFile(input_api, output_api))
rmistry@google.com8e3ff8c2013-01-17 12:55:34 +0000486 return results