blob: f7cbeb94d7353178a12f1336768d09a2622109b8 [file] [log] [blame]
Doug Anderson44a644f2011-11-02 10:37:37 -07001#!/usr/bin/env python
Jon Salz98255932012-08-18 14:48:02 +08002# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
Mandeep Singh Baines116ad102011-04-27 15:16:37 -07003# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
Ryan Cui9b651632011-05-11 11:38:58 -07006import ConfigParser
Jon Salz3ee59de2012-08-18 13:54:22 +08007import functools
Dale Curtis2975c432011-05-03 17:25:20 -07008import json
Doug Anderson44a644f2011-11-02 10:37:37 -07009import optparse
Mandeep Singh Baines116ad102011-04-27 15:16:37 -070010import os
Ryan Cuiec4d6332011-05-02 14:15:25 -070011import re
David Hendricks4c018e72013-02-06 13:46:38 -080012import socket
Mandeep Singh Bainesa7ffa4b2011-05-03 11:37:02 -070013import sys
Mandeep Singh Baines116ad102011-04-27 15:16:37 -070014import subprocess
15
Ryan Cui1562fb82011-05-09 11:01:31 -070016from errors import (VerifyException, HookFailure, PrintErrorForProject,
17 PrintErrorsForCommit)
Ryan Cuiec4d6332011-05-02 14:15:25 -070018
David Jamesc3b68b32013-04-03 09:17:03 -070019# If repo imports us, the __name__ will be __builtin__, and the wrapper will
20# be in $CHROMEOS_CHECKOUT/.repo/repo/main.py, so we need to go two directories
21# up. The same logic also happens to work if we're executed directly.
22if __name__ in ('__builtin__', '__main__'):
23 sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..', '..'))
24
25from chromite.lib import patch
26
Ryan Cuiec4d6332011-05-02 14:15:25 -070027
28COMMON_INCLUDED_PATHS = [
29 # C++ and friends
30 r".*\.c$", r".*\.cc$", r".*\.cpp$", r".*\.h$", r".*\.m$", r".*\.mm$",
31 r".*\.inl$", r".*\.asm$", r".*\.hxx$", r".*\.hpp$", r".*\.s$", r".*\.S$",
32 # Scripts
33 r".*\.js$", r".*\.py$", r".*\.sh$", r".*\.rb$", r".*\.pl$", r".*\.pm$",
34 # No extension at all, note that ALL CAPS files are black listed in
35 # COMMON_EXCLUDED_LIST below.
David Hendricks0af30eb2013-02-05 11:35:56 -080036 r"(^|.*[\\\/])[^.]+$",
Ryan Cuiec4d6332011-05-02 14:15:25 -070037 # Other
38 r".*\.java$", r".*\.mk$", r".*\.am$",
39]
40
Ryan Cui1562fb82011-05-09 11:01:31 -070041
Ryan Cuiec4d6332011-05-02 14:15:25 -070042COMMON_EXCLUDED_PATHS = [
Ryan Cui31e0c172011-05-04 21:00:45 -070043 # avoid doing source file checks for kernel
44 r"/src/third_party/kernel/",
45 r"/src/third_party/kernel-next/",
Paul Taysomf8b6e012011-05-09 14:32:42 -070046 r"/src/third_party/ktop/",
47 r"/src/third_party/punybench/",
Ryan Cuiec4d6332011-05-02 14:15:25 -070048 r".*\bexperimental[\\\/].*",
49 r".*\b[A-Z0-9_]{2,}$",
50 r".*[\\\/]debian[\\\/]rules$",
Brian Harringd780d602011-10-18 16:48:08 -070051 # for ebuild trees, ignore any caches and manifest data
52 r".*/Manifest$",
53 r".*/metadata/[^/]*cache[^/]*/[^/]+/[^/]+$",
Doug Anderson5bfb6792011-10-25 16:45:41 -070054
55 # ignore profiles data (like overlay-tegra2/profiles)
56 r".*/overlay-.*/profiles/.*",
Andrew de los Reyes0e679922012-05-02 11:42:54 -070057 # ignore minified js and jquery
58 r".*\.min\.js",
59 r".*jquery.*\.js",
Ryan Cuiec4d6332011-05-02 14:15:25 -070060]
Mandeep Singh Baines116ad102011-04-27 15:16:37 -070061
Ryan Cui1562fb82011-05-09 11:01:31 -070062
Ryan Cui9b651632011-05-11 11:38:58 -070063_CONFIG_FILE = 'PRESUBMIT.cfg'
64
65
Doug Anderson44a644f2011-11-02 10:37:37 -070066# Exceptions
67
68
69class BadInvocation(Exception):
70 """An Exception indicating a bad invocation of the program."""
71 pass
72
73
Ryan Cui1562fb82011-05-09 11:01:31 -070074# General Helpers
75
Sean Paulba01d402011-05-05 11:36:23 -040076
Doug Anderson44a644f2011-11-02 10:37:37 -070077def _run_command(cmd, cwd=None, stderr=None):
78 """Executes the passed in command and returns raw stdout output.
79
80 Args:
81 cmd: The command to run; should be a list of strings.
82 cwd: The directory to switch to for running the command.
83 stderr: Can be one of None (print stderr to console), subprocess.STDOUT
84 (combine stderr with stdout), or subprocess.PIPE (ignore stderr).
85
86 Returns:
87 The standard out from the process.
88 """
89 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=stderr, cwd=cwd)
90 return p.communicate()[0]
Ryan Cui72834d12011-05-05 14:51:33 -070091
Ryan Cui1562fb82011-05-09 11:01:31 -070092
Mandeep Singh Baines116ad102011-04-27 15:16:37 -070093def _get_hooks_dir():
Ryan Cuiec4d6332011-05-02 14:15:25 -070094 """Returns the absolute path to the repohooks directory."""
Doug Anderson44a644f2011-11-02 10:37:37 -070095 if __name__ == '__main__':
96 # Works when file is run on its own (__file__ is defined)...
97 return os.path.abspath(os.path.dirname(__file__))
98 else:
99 # We need to do this when we're run through repo. Since repo executes
100 # us with execfile(), we don't get __file__ defined.
101 cmd = ['repo', 'forall', 'chromiumos/repohooks', '-c', 'pwd']
102 return _run_command(cmd).strip()
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700103
Ryan Cui1562fb82011-05-09 11:01:31 -0700104
Ryan Cuiec4d6332011-05-02 14:15:25 -0700105def _match_regex_list(subject, expressions):
106 """Try to match a list of regular expressions to a string.
107
108 Args:
109 subject: The string to match regexes on
110 expressions: A list of regular expressions to check for matches with.
111
112 Returns:
113 Whether the passed in subject matches any of the passed in regexes.
114 """
115 for expr in expressions:
116 if (re.search(expr, subject)):
117 return True
118 return False
119
Ryan Cui1562fb82011-05-09 11:01:31 -0700120
Ryan Cuiec4d6332011-05-02 14:15:25 -0700121def _filter_files(files, include_list, exclude_list=[]):
122 """Filter out files based on the conditions passed in.
123
124 Args:
125 files: list of filepaths to filter
126 include_list: list of regex that when matched with a file path will cause it
127 to be added to the output list unless the file is also matched with a
128 regex in the exclude_list.
129 exclude_list: list of regex that when matched with a file will prevent it
130 from being added to the output list, even if it is also matched with a
131 regex in the include_list.
132
133 Returns:
134 A list of filepaths that contain files matched in the include_list and not
135 in the exclude_list.
136 """
137 filtered = []
138 for f in files:
139 if (_match_regex_list(f, include_list) and
140 not _match_regex_list(f, exclude_list)):
141 filtered.append(f)
142 return filtered
143
Ryan Cuiec4d6332011-05-02 14:15:25 -0700144
David Hendricks35030d02013-02-04 17:49:16 -0800145def _verify_header_content(commit, content, fail_msg):
146 """Verify that file headers contain specified content.
147
148 Args:
149 commit: the affected commit.
150 content: the content of the header to be verified.
151 fail_msg: the first message to display in case of failure.
152
153 Returns:
154 The return value of HookFailure().
155 """
156 license_re = re.compile(content, re.MULTILINE)
157 bad_files = []
158 files = _filter_files(_get_affected_files(commit),
159 COMMON_INCLUDED_PATHS,
160 COMMON_EXCLUDED_PATHS)
161
162 for f in files:
Gabe Blackcf3c32c2013-02-27 00:26:13 -0800163 if os.path.exists(f): # Ignore non-existant files
164 contents = open(f).read()
165 if len(contents) == 0: continue # Ignore empty files
166 if not license_re.search(contents):
167 bad_files.append(f)
David Hendricks35030d02013-02-04 17:49:16 -0800168 if bad_files:
169 msg = "%s:\n%s\n%s" % (fail_msg, license_re.pattern,
170 "Found a bad header in these files:")
171 return HookFailure(msg, bad_files)
172
173
Ryan Cuiec4d6332011-05-02 14:15:25 -0700174# Git Helpers
Ryan Cui1562fb82011-05-09 11:01:31 -0700175
176
Ryan Cui4725d952011-05-05 15:41:19 -0700177def _get_upstream_branch():
178 """Returns the upstream tracking branch of the current branch.
179
180 Raises:
181 Error if there is no tracking branch
182 """
183 current_branch = _run_command(['git', 'symbolic-ref', 'HEAD']).strip()
184 current_branch = current_branch.replace('refs/heads/', '')
185 if not current_branch:
Ryan Cui1562fb82011-05-09 11:01:31 -0700186 raise VerifyException('Need to be on a tracking branch')
Ryan Cui4725d952011-05-05 15:41:19 -0700187
188 cfg_option = 'branch.' + current_branch + '.%s'
189 full_upstream = _run_command(['git', 'config', cfg_option % 'merge']).strip()
190 remote = _run_command(['git', 'config', cfg_option % 'remote']).strip()
191 if not remote or not full_upstream:
Ryan Cui1562fb82011-05-09 11:01:31 -0700192 raise VerifyException('Need to be on a tracking branch')
Ryan Cui4725d952011-05-05 15:41:19 -0700193
194 return full_upstream.replace('heads', 'remotes/' + remote)
195
Ryan Cui1562fb82011-05-09 11:01:31 -0700196
Che-Liang Chiou5ce2d7b2013-03-22 18:47:55 -0700197def _get_patch(commit):
198 """Returns the patch for this commit."""
199 return _run_command(['git', 'format-patch', '--stdout', '-1', commit])
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700200
Ryan Cui1562fb82011-05-09 11:01:31 -0700201
Jon Salz98255932012-08-18 14:48:02 +0800202def _try_utf8_decode(data):
203 """Attempts to decode a string as UTF-8.
204
205 Returns:
206 The decoded Unicode object, or the original string if parsing fails.
207 """
208 try:
209 return unicode(data, 'utf-8', 'strict')
210 except UnicodeDecodeError:
211 return data
212
213
Ryan Cuiec4d6332011-05-02 14:15:25 -0700214def _get_file_diff(file, commit):
215 """Returns a list of (linenum, lines) tuples that the commit touched."""
Ryan Cui72834d12011-05-05 14:51:33 -0700216 output = _run_command(['git', 'show', '-p', '--no-ext-diff', commit, file])
Ryan Cuiec4d6332011-05-02 14:15:25 -0700217
218 new_lines = []
219 line_num = 0
220 for line in output.splitlines():
221 m = re.match(r'^@@ [0-9\,\+\-]+ \+([0-9]+)\,[0-9]+ @@', line)
222 if m:
223 line_num = int(m.groups(1)[0])
224 continue
225 if line.startswith('+') and not line.startswith('++'):
Jon Salz98255932012-08-18 14:48:02 +0800226 new_lines.append((line_num, _try_utf8_decode(line[1:])))
Ryan Cuiec4d6332011-05-02 14:15:25 -0700227 if not line.startswith('-'):
228 line_num += 1
229 return new_lines
230
Ryan Cui1562fb82011-05-09 11:01:31 -0700231
Doug Anderson42b8a052013-06-26 10:45:36 -0700232def _get_affected_files(commit, include_deletes=False):
233 """Returns list of absolute filepaths that were modified/added.
234
235 Args:
236 commit: The commit
237 include_deletes: If true we'll include delete in the list.
238
239 Returns:
240 A list of modified/added (and perhaps deleted) files
241 """
Ryan Cui72834d12011-05-05 14:51:33 -0700242 output = _run_command(['git', 'diff', '--name-status', commit + '^!'])
Ryan Cuiec4d6332011-05-02 14:15:25 -0700243 files = []
244 for statusline in output.splitlines():
245 m = re.match('^(\w)+\t(.+)$', statusline.rstrip())
246 # Ignore deleted files, and return absolute paths of files
Doug Anderson42b8a052013-06-26 10:45:36 -0700247 if (include_deletes or m.group(1)[0] != 'D'):
Ryan Cuiec4d6332011-05-02 14:15:25 -0700248 pwd = os.getcwd()
249 files.append(os.path.join(pwd, m.group(2)))
250 return files
251
Ryan Cui1562fb82011-05-09 11:01:31 -0700252
Mandeep Singh Bainesb9ed1402011-04-29 15:32:06 -0700253def _get_commits():
Ryan Cuiec4d6332011-05-02 14:15:25 -0700254 """Returns a list of commits for this review."""
Ryan Cui4725d952011-05-05 15:41:19 -0700255 cmd = ['git', 'log', '%s..' % _get_upstream_branch(), '--format=%H']
Ryan Cui72834d12011-05-05 14:51:33 -0700256 return _run_command(cmd).split()
Mandeep Singh Bainesb9ed1402011-04-29 15:32:06 -0700257
Ryan Cui1562fb82011-05-09 11:01:31 -0700258
Ryan Cuiec4d6332011-05-02 14:15:25 -0700259def _get_commit_desc(commit):
260 """Returns the full commit message of a commit."""
Sean Paul23a2c582011-05-06 13:10:44 -0400261 return _run_command(['git', 'log', '--format=%s%n%n%b', commit + '^!'])
Ryan Cuiec4d6332011-05-02 14:15:25 -0700262
263
264# Common Hooks
265
Ryan Cui1562fb82011-05-09 11:01:31 -0700266
Ryan Cuiec4d6332011-05-02 14:15:25 -0700267def _check_no_long_lines(project, commit):
268 """Checks that there aren't any lines longer than maxlen characters in any of
269 the text files to be submitted.
270 """
271 MAX_LEN = 80
Jon Salz98255932012-08-18 14:48:02 +0800272 SKIP_REGEXP = re.compile('|'.join([
273 r'https?://',
274 r'^#\s*(define|include|import|pragma|if|endif)\b']))
Ryan Cuiec4d6332011-05-02 14:15:25 -0700275
276 errors = []
277 files = _filter_files(_get_affected_files(commit),
278 COMMON_INCLUDED_PATHS,
279 COMMON_EXCLUDED_PATHS)
280
281 for afile in files:
282 for line_num, line in _get_file_diff(afile, commit):
283 # Allow certain lines to exceed the maxlen rule.
Jon Salz98255932012-08-18 14:48:02 +0800284 if (len(line) <= MAX_LEN or SKIP_REGEXP.search(line)):
285 continue
286
287 errors.append('%s, line %s, %s chars' % (afile, line_num, len(line)))
288 if len(errors) == 5: # Just show the first 5 errors.
289 break
Ryan Cuiec4d6332011-05-02 14:15:25 -0700290
291 if errors:
292 msg = 'Found lines longer than %s characters (first 5 shown):' % MAX_LEN
Ryan Cui1562fb82011-05-09 11:01:31 -0700293 return HookFailure(msg, errors)
294
Ryan Cuiec4d6332011-05-02 14:15:25 -0700295
296def _check_no_stray_whitespace(project, commit):
297 """Checks that there is no stray whitespace at source lines end."""
298 errors = []
299 files = _filter_files(_get_affected_files(commit),
300 COMMON_INCLUDED_PATHS,
301 COMMON_EXCLUDED_PATHS)
302
303 for afile in files:
304 for line_num, line in _get_file_diff(afile, commit):
305 if line.rstrip() != line:
306 errors.append('%s, line %s' % (afile, line_num))
307 if errors:
Ryan Cui1562fb82011-05-09 11:01:31 -0700308 return HookFailure('Found line ending with white space in:', errors)
309
Ryan Cuiec4d6332011-05-02 14:15:25 -0700310
311def _check_no_tabs(project, commit):
312 """Checks there are no unexpanded tabs."""
313 TAB_OK_PATHS = [
Ryan Cui31e0c172011-05-04 21:00:45 -0700314 r"/src/third_party/u-boot/",
Ryan Cuiec4d6332011-05-02 14:15:25 -0700315 r".*\.ebuild$",
316 r".*\.eclass$",
Elly Jones5ab34192011-11-15 14:57:06 -0500317 r".*/[M|m]akefile$",
318 r".*\.mk$"
Ryan Cuiec4d6332011-05-02 14:15:25 -0700319 ]
320
321 errors = []
322 files = _filter_files(_get_affected_files(commit),
323 COMMON_INCLUDED_PATHS,
324 COMMON_EXCLUDED_PATHS + TAB_OK_PATHS)
325
326 for afile in files:
327 for line_num, line in _get_file_diff(afile, commit):
328 if '\t' in line:
329 errors.append('%s, line %s' % (afile, line_num))
330 if errors:
Ryan Cui1562fb82011-05-09 11:01:31 -0700331 return HookFailure('Found a tab character in:', errors)
332
Ryan Cuiec4d6332011-05-02 14:15:25 -0700333
334def _check_change_has_test_field(project, commit):
335 """Check for a non-empty 'TEST=' field in the commit message."""
David McMahon8f6553e2011-06-10 15:46:36 -0700336 TEST_RE = r'\nTEST=\S+'
Ryan Cuiec4d6332011-05-02 14:15:25 -0700337
Mandeep Singh Baines96a53be2011-05-03 11:10:25 -0700338 if not re.search(TEST_RE, _get_commit_desc(commit)):
Ryan Cui1562fb82011-05-09 11:01:31 -0700339 msg = 'Changelist description needs TEST field (after first line)'
340 return HookFailure(msg)
341
Ryan Cuiec4d6332011-05-02 14:15:25 -0700342
David Jamesc3b68b32013-04-03 09:17:03 -0700343def _check_change_has_valid_cq_depend(project, commit):
344 """Check for a correctly formatted CQ-DEPEND field in the commit message."""
345 msg = 'Changelist has invalid CQ-DEPEND target.'
346 example = 'Example: CQ-DEPEND=CL:1234, CL:2345'
347 try:
348 patch.GetPaladinDeps(_get_commit_desc(commit))
349 except ValueError as ex:
350 return HookFailure(msg, [example, str(ex)])
351
352
Ryan Cuiec4d6332011-05-02 14:15:25 -0700353def _check_change_has_bug_field(project, commit):
David McMahon8f6553e2011-06-10 15:46:36 -0700354 """Check for a correctly formatted 'BUG=' field in the commit message."""
David James5c0073d2013-04-03 08:48:52 -0700355 OLD_BUG_RE = r'\nBUG=.*chromium-os'
356 if re.search(OLD_BUG_RE, _get_commit_desc(commit)):
357 msg = ('The chromium-os bug tracker is now deprecated. Please use\n'
358 'the chromium tracker in your BUG= line now.')
359 return HookFailure(msg)
Ryan Cuiec4d6332011-05-02 14:15:25 -0700360
David James5c0073d2013-04-03 08:48:52 -0700361 BUG_RE = r'\nBUG=([Nn]one|(chrome-os-partner|chromium):\d+)'
Mandeep Singh Baines96a53be2011-05-03 11:10:25 -0700362 if not re.search(BUG_RE, _get_commit_desc(commit)):
David McMahon8f6553e2011-06-10 15:46:36 -0700363 msg = ('Changelist description needs BUG field (after first line):\n'
David James5c0073d2013-04-03 08:48:52 -0700364 'BUG=chromium:9999 (for public tracker)\n'
David McMahon8f6553e2011-06-10 15:46:36 -0700365 'BUG=chrome-os-partner:9999 (for partner tracker)\n'
366 'BUG=None')
Ryan Cui1562fb82011-05-09 11:01:31 -0700367 return HookFailure(msg)
368
Ryan Cuiec4d6332011-05-02 14:15:25 -0700369
Doug Anderson42b8a052013-06-26 10:45:36 -0700370def _check_for_uprev(project, commit):
371 """Check that we're not missing a revbump of an ebuild in the given commit.
372
373 If the given commit touches files in a directory that has ebuilds somewhere
374 up the directory hierarchy, it's very likely that we need an ebuild revbump
375 in order for those changes to take effect.
376
377 It's not totally trivial to detect a revbump, so at least detect that an
378 ebuild with a revision number in it was touched. This should handle the
379 common case where we use a symlink to do the revbump.
380
381 TODO: it would be nice to enhance this hook to:
382 * Handle cases where people revbump with a slightly different syntax. I see
383 one ebuild (puppy) that revbumps with _pN. This is a false positive.
384 * Catches cases where people aren't using symlinks for revbumps. If they
385 edit a revisioned file directly (and are expected to rename it for revbump)
386 we'll miss that. Perhaps we could detect that the file touched is a
387 symlink?
388
389 If a project doesn't use symlinks we'll potentially miss a revbump, but we're
390 still better off than without this check.
391
392 Args:
393 project: The project to look at
394 commit: The commit to look at
395
396 Returns:
397 A HookFailure or None.
398 """
399 affected_paths = _get_affected_files(commit, include_deletes=True)
400
401 # Don't yell about changes to whitelisted files...
402 whitelist = ['Manifest', 'metadata.xml']
403 affected_paths = [path for path in affected_paths
404 if os.path.basename(path) not in whitelist]
405 if not affected_paths:
406 return None
407
408 # If we've touched any file named with a -rN.ebuild then we'll say we're
409 # OK right away. See TODO above about enhancing this.
410 touched_revved_ebuild = any(re.search(r'-r\d*\.ebuild$', path)
411 for path in affected_paths)
412 if touched_revved_ebuild:
413 return None
414
415 # We want to examine the current contents of all directories that are parents
416 # of files that were touched (up to the top of the project).
417 #
418 # ...note: we use the current directory contents even though it may have
419 # changed since the commit we're looking at. This is just a heuristic after
420 # all. Worst case we don't flag a missing revbump.
421 project_top = os.getcwd()
422 dirs_to_check = set([project_top])
423 for path in affected_paths:
424 path = os.path.dirname(path)
425 while os.path.exists(path) and not os.path.samefile(path, project_top):
426 dirs_to_check.add(path)
427 path = os.path.dirname(path)
428
429 # Look through each directory. If it's got an ebuild in it then we'll
430 # consider this as a case when we need a revbump.
431 for dir_path in dirs_to_check:
432 contents = os.listdir(dir_path)
433 ebuilds = [os.path.join(dir_path, path)
434 for path in contents if path.endswith('.ebuild')]
435 ebuilds_9999 = [path for path in ebuilds if path.endswith('-9999.ebuild')]
436
437 # If the -9999.ebuild file was touched the bot will uprev for us.
438 # ...we'll use a simple intersection here as a heuristic...
439 if set(ebuilds_9999) & set(affected_paths):
440 continue
441
442 if ebuilds:
443 return HookFailure('Changelist probably needs a revbump of an ebuild\n'
444 'or a -r1.ebuild symlink if this is a new ebuild')
445
446 return None
447
448
Mandeep Singh Bainesa23eb5f2011-05-04 13:43:25 -0700449def _check_change_has_proper_changeid(project, commit):
450 """Verify that Change-ID is present in last paragraph of commit message."""
451 desc = _get_commit_desc(commit)
452 loc = desc.rfind('\nChange-Id:')
453 if loc == -1 or re.search('\n\s*\n\s*\S+', desc[loc:]):
Ryan Cui1562fb82011-05-09 11:01:31 -0700454 return HookFailure('Change-Id must be in last paragraph of description.')
455
Mandeep Singh Bainesa23eb5f2011-05-04 13:43:25 -0700456
Ryan Cuiec4d6332011-05-02 14:15:25 -0700457def _check_license(project, commit):
458 """Verifies the license header."""
459 LICENSE_HEADER = (
David Hendricks0af30eb2013-02-05 11:35:56 -0800460 r".* Copyright \(c\) 20[-0-9]{2,7} The Chromium OS Authors\. All rights "
Ryan Cuiec4d6332011-05-02 14:15:25 -0700461 r"reserved\." "\n"
David Hendricks0af30eb2013-02-05 11:35:56 -0800462 r".* Use of this source code is governed by a BSD-style license that can "
Ryan Cuiec4d6332011-05-02 14:15:25 -0700463 "be\n"
David Hendricks0af30eb2013-02-05 11:35:56 -0800464 r".* found in the LICENSE file\."
Ryan Cuiec4d6332011-05-02 14:15:25 -0700465 "\n"
466 )
David Hendricks35030d02013-02-04 17:49:16 -0800467 FAIL_MSG = "License must match"
Ryan Cuiec4d6332011-05-02 14:15:25 -0700468
David Hendricks35030d02013-02-04 17:49:16 -0800469 return _verify_header_content(commit, LICENSE_HEADER, FAIL_MSG)
Ryan Cuiec4d6332011-05-02 14:15:25 -0700470
471
David Hendricksa0e310d2013-02-04 17:51:55 -0800472def _check_google_copyright(project, commit):
473 """Verifies Google Inc. as copyright holder."""
474 LICENSE_HEADER = (
475 r".* Copyright 20[-0-9]{2,7} Google Inc\."
476 )
477 FAIL_MSG = "Copyright must match"
478
David Hendricks4c018e72013-02-06 13:46:38 -0800479 # Avoid blocking partners and external contributors.
480 fqdn = socket.getfqdn()
481 if not fqdn.endswith(".corp.google.com"):
482 return None
483
David Hendricksa0e310d2013-02-04 17:51:55 -0800484 return _verify_header_content(commit, LICENSE_HEADER, FAIL_MSG)
485
486
Ryan Cuiec4d6332011-05-02 14:15:25 -0700487# Project-specific hooks
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700488
Ryan Cui1562fb82011-05-09 11:01:31 -0700489
Anton Staaf815d6852011-08-22 10:08:45 -0700490def _run_checkpatch(project, commit, options=[]):
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700491 """Runs checkpatch.pl on the given project"""
492 hooks_dir = _get_hooks_dir()
Anton Staaf815d6852011-08-22 10:08:45 -0700493 cmd = ['%s/checkpatch.pl' % hooks_dir] + options + ['-']
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700494 p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
Che-Liang Chiou5ce2d7b2013-03-22 18:47:55 -0700495 output = p.communicate(_get_patch(commit))[0]
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700496 if p.returncode:
Ryan Cui1562fb82011-05-09 11:01:31 -0700497 return HookFailure('checkpatch.pl errors/warnings\n\n' + output)
Ryan Cuiec4d6332011-05-02 14:15:25 -0700498
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700499
Anton Staaf815d6852011-08-22 10:08:45 -0700500def _run_checkpatch_no_tree(project, commit):
501 return _run_checkpatch(project, commit, ['--no-tree'])
502
Olof Johanssona96810f2012-09-04 16:20:03 -0700503def _kernel_configcheck(project, commit):
504 """Makes sure kernel config changes are not mixed with code changes"""
505 files = _get_affected_files(commit)
506 if not len(_filter_files(files, [r'chromeos/config'])) in [0, len(files)]:
507 return HookFailure('Changes to chromeos/config/ and regular files must '
508 'be in separate commits:\n%s' % '\n'.join(files))
Anton Staaf815d6852011-08-22 10:08:45 -0700509
Dale Curtis2975c432011-05-03 17:25:20 -0700510def _run_json_check(project, commit):
511 """Checks that all JSON files are syntactically valid."""
Dale Curtisa039cfd2011-05-04 12:01:05 -0700512 for f in _filter_files(_get_affected_files(commit), [r'.*\.json']):
Dale Curtis2975c432011-05-03 17:25:20 -0700513 try:
514 json.load(open(f))
515 except Exception, e:
Ryan Cui1562fb82011-05-09 11:01:31 -0700516 return HookFailure('Invalid JSON in %s: %s' % (f, e))
Dale Curtis2975c432011-05-03 17:25:20 -0700517
518
Mike Frysinger52b537e2013-08-22 22:59:53 -0400519def _check_manifests(project, commit):
520 """Make sure Manifest files only have DIST lines"""
521 paths = []
522
523 for path in _get_affected_files(commit):
524 if os.path.basename(path) != 'Manifest':
525 continue
526 if not os.path.exists(path):
527 continue
528
529 with open(path, 'r') as f:
530 for line in f.readlines():
531 if not line.startswith('DIST '):
532 paths.append(path)
533 break
534
535 if paths:
536 return HookFailure('Please remove lines that do not start with DIST:\n%s' %
537 ('\n'.join(paths),))
538
539
Puneet Kumarc80e3f62012-08-13 19:01:18 -0700540def _check_change_has_branch_field(project, commit):
541 """Check for a non-empty 'BRANCH=' field in the commit message."""
542 BRANCH_RE = r'\nBRANCH=\S+'
543
544 if not re.search(BRANCH_RE, _get_commit_desc(commit)):
545 msg = ('Changelist description needs BRANCH field (after first line)\n'
546 'E.g. BRANCH=none or BRANCH=link,snow')
547 return HookFailure(msg)
548
549
Jon Salz3ee59de2012-08-18 13:54:22 +0800550def _run_project_hook_script(script, project, commit):
551 """Runs a project hook script.
552
553 The script is run with the following environment variables set:
554 PRESUBMIT_PROJECT: The affected project
555 PRESUBMIT_COMMIT: The affected commit
556 PRESUBMIT_FILES: A newline-separated list of affected files
557
558 The script is considered to fail if the exit code is non-zero. It should
559 write an error message to stdout.
560 """
561 env = dict(os.environ)
562 env['PRESUBMIT_PROJECT'] = project
563 env['PRESUBMIT_COMMIT'] = commit
564
565 # Put affected files in an environment variable
566 files = _get_affected_files(commit)
567 env['PRESUBMIT_FILES'] = '\n'.join(files)
568
569 process = subprocess.Popen(script, env=env, shell=True,
570 stdin=open(os.devnull),
Jon Salz7b618af2012-08-31 06:03:16 +0800571 stdout=subprocess.PIPE,
572 stderr=subprocess.STDOUT)
Jon Salz3ee59de2012-08-18 13:54:22 +0800573 stdout, _ = process.communicate()
574 if process.wait():
Jon Salz7b618af2012-08-31 06:03:16 +0800575 if stdout:
576 stdout = re.sub('(?m)^', ' ', stdout)
577 return HookFailure('Hook script "%s" failed with code %d%s' %
Jon Salz3ee59de2012-08-18 13:54:22 +0800578 (script, process.returncode,
579 ':\n' + stdout if stdout else ''))
580
581
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700582# Base
583
Ryan Cui1562fb82011-05-09 11:01:31 -0700584
Ryan Cui9b651632011-05-11 11:38:58 -0700585# A list of hooks that are not project-specific
586_COMMON_HOOKS = [
587 _check_change_has_bug_field,
David Jamesc3b68b32013-04-03 09:17:03 -0700588 _check_change_has_valid_cq_depend,
Ryan Cui9b651632011-05-11 11:38:58 -0700589 _check_change_has_test_field,
590 _check_change_has_proper_changeid,
591 _check_no_stray_whitespace,
592 _check_no_long_lines,
593 _check_license,
594 _check_no_tabs,
Doug Anderson42b8a052013-06-26 10:45:36 -0700595 _check_for_uprev,
Ryan Cui9b651632011-05-11 11:38:58 -0700596]
Ryan Cuiec4d6332011-05-02 14:15:25 -0700597
Ryan Cui1562fb82011-05-09 11:01:31 -0700598
Ryan Cui9b651632011-05-11 11:38:58 -0700599# A dictionary of project-specific hooks(callbacks), indexed by project name.
600# dict[project] = [callback1, callback2]
601_PROJECT_SPECIFIC_HOOKS = {
Mike Frysingerdf980702013-08-22 22:25:22 -0400602 "chromeos/autotest-tools": [_run_json_check],
Mike Frysinger52b537e2013-08-22 22:59:53 -0400603 "chromeos/overlays/chromeos-overlay": [_check_manifests],
604 "chromeos/overlays/chromeos-partner-overlay": [_check_manifests],
Puneet Kumarc80e3f62012-08-13 19:01:18 -0700605 "chromeos/platform/ec-private": [_run_checkpatch_no_tree,
606 _check_change_has_branch_field],
David Hendricks33f77d52013-02-04 17:53:02 -0800607 "chromeos/third_party/coreboot": [_check_change_has_branch_field,
608 _check_google_copyright],
Puneet Kumar57b9c092012-08-14 18:58:29 -0700609 "chromeos/third_party/intel-framework": [_check_change_has_branch_field],
Mike Frysingerdf980702013-08-22 22:25:22 -0400610 "chromeos/vendor/kernel-exynos-staging": [_run_checkpatch,
611 _kernel_configcheck],
612 "chromeos/vendor/u-boot-exynos": [_run_checkpatch_no_tree],
Mike Frysinger52b537e2013-08-22 22:59:53 -0400613 "chromiumos/overlays/board-overlays": [_check_manifests],
614 "chromiumos/overlays/chromiumos-overlay": [_check_manifests],
615 "chromiumos/overlays/portage-stable": [_check_manifests],
Mike Frysingerdf980702013-08-22 22:25:22 -0400616 "chromiumos/platform/ec": [_run_checkpatch_no_tree,
617 _check_change_has_branch_field],
Puneet Kumarc80e3f62012-08-13 19:01:18 -0700618 "chromiumos/platform/mosys": [_check_change_has_branch_field],
Mike Frysingerdf980702013-08-22 22:25:22 -0400619 "chromiumos/platform/vboot_reference": [_check_change_has_branch_field],
Puneet Kumarc80e3f62012-08-13 19:01:18 -0700620 "chromiumos/third_party/flashrom": [_check_change_has_branch_field],
Mike Frysingerdf980702013-08-22 22:25:22 -0400621 "chromiumos/third_party/kernel": [_run_checkpatch, _kernel_configcheck],
622 "chromiumos/third_party/kernel-next": [_run_checkpatch,
623 _kernel_configcheck],
624 "chromiumos/third_party/u-boot": [_run_checkpatch_no_tree,
625 _check_change_has_branch_field],
Ryan Cui9b651632011-05-11 11:38:58 -0700626}
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700627
Ryan Cui1562fb82011-05-09 11:01:31 -0700628
Ryan Cui9b651632011-05-11 11:38:58 -0700629# A dictionary of flags (keys) that can appear in the config file, and the hook
630# that the flag disables (value)
631_DISABLE_FLAGS = {
632 'stray_whitespace_check': _check_no_stray_whitespace,
633 'long_line_check': _check_no_long_lines,
634 'cros_license_check': _check_license,
635 'tab_check': _check_no_tabs,
Puneet Kumarc80e3f62012-08-13 19:01:18 -0700636 'branch_check': _check_change_has_branch_field,
Ryan Cui9b651632011-05-11 11:38:58 -0700637}
638
639
Jon Salz3ee59de2012-08-18 13:54:22 +0800640def _get_disabled_hooks(config):
Ryan Cui9b651632011-05-11 11:38:58 -0700641 """Returns a set of hooks disabled by the current project's config file.
642
643 Expects to be called within the project root.
Jon Salz3ee59de2012-08-18 13:54:22 +0800644
645 Args:
646 config: A ConfigParser for the project's config file.
Ryan Cui9b651632011-05-11 11:38:58 -0700647 """
648 SECTION = 'Hook Overrides'
Jon Salz3ee59de2012-08-18 13:54:22 +0800649 if not config.has_section(SECTION):
650 return set()
Ryan Cui9b651632011-05-11 11:38:58 -0700651
652 disable_flags = []
Jon Salz3ee59de2012-08-18 13:54:22 +0800653 for flag in config.options(SECTION):
Ryan Cui9b651632011-05-11 11:38:58 -0700654 try:
655 if not config.getboolean(SECTION, flag): disable_flags.append(flag)
656 except ValueError as e:
657 msg = "Error parsing flag \'%s\' in %s file - " % (flag, _CONFIG_FILE)
658 print msg + str(e)
659
660 disabled_keys = set(_DISABLE_FLAGS.iterkeys()).intersection(disable_flags)
661 return set([_DISABLE_FLAGS[key] for key in disabled_keys])
662
663
Jon Salz3ee59de2012-08-18 13:54:22 +0800664def _get_project_hook_scripts(config):
665 """Returns a list of project-specific hook scripts.
666
667 Args:
668 config: A ConfigParser for the project's config file.
669 """
670 SECTION = 'Hook Scripts'
671 if not config.has_section(SECTION):
672 return []
673
674 hook_names_values = config.items(SECTION)
675 hook_names_values.sort(key=lambda x: x[0])
676 return [x[1] for x in hook_names_values]
677
678
Ryan Cui9b651632011-05-11 11:38:58 -0700679def _get_project_hooks(project):
680 """Returns a list of hooks that need to be run for a project.
681
682 Expects to be called from within the project root.
683 """
Jon Salz3ee59de2012-08-18 13:54:22 +0800684 config = ConfigParser.RawConfigParser()
685 try:
686 config.read(_CONFIG_FILE)
687 except ConfigParser.Error:
688 # Just use an empty config file
689 config = ConfigParser.RawConfigParser()
690
691 disabled_hooks = _get_disabled_hooks(config)
Ryan Cui9b651632011-05-11 11:38:58 -0700692 hooks = [hook for hook in _COMMON_HOOKS if hook not in disabled_hooks]
693
694 if project in _PROJECT_SPECIFIC_HOOKS:
Puneet Kumarc80e3f62012-08-13 19:01:18 -0700695 hooks.extend(hook for hook in _PROJECT_SPECIFIC_HOOKS[project]
696 if hook not in disabled_hooks)
Ryan Cui9b651632011-05-11 11:38:58 -0700697
Jon Salz3ee59de2012-08-18 13:54:22 +0800698 for script in _get_project_hook_scripts(config):
699 hooks.append(functools.partial(_run_project_hook_script, script))
700
Ryan Cui9b651632011-05-11 11:38:58 -0700701 return hooks
702
703
Doug Anderson14749562013-06-26 13:38:29 -0700704def _run_project_hooks(project, proj_dir=None, commit_list=None):
Ryan Cui1562fb82011-05-09 11:01:31 -0700705 """For each project run its project specific hook from the hooks dictionary.
706
707 Args:
Doug Anderson44a644f2011-11-02 10:37:37 -0700708 project: The name of project to run hooks for.
709 proj_dir: If non-None, this is the directory the project is in. If None,
710 we'll ask repo.
Doug Anderson14749562013-06-26 13:38:29 -0700711 commit_list: A list of commits to run hooks against. If None or empty list
712 then we'll automatically get the list of commits that would be uploaded.
Ryan Cui1562fb82011-05-09 11:01:31 -0700713
714 Returns:
715 Boolean value of whether any errors were ecountered while running the hooks.
716 """
Doug Anderson44a644f2011-11-02 10:37:37 -0700717 if proj_dir is None:
718 proj_dir = _run_command(['repo', 'forall', project, '-c', 'pwd']).strip()
719
Ryan Cuiec4d6332011-05-02 14:15:25 -0700720 pwd = os.getcwd()
721 # hooks assume they are run from the root of the project
722 os.chdir(proj_dir)
723
Doug Anderson14749562013-06-26 13:38:29 -0700724 if not commit_list:
725 try:
726 commit_list = _get_commits()
727 except VerifyException as e:
728 PrintErrorForProject(project, HookFailure(str(e)))
729 os.chdir(pwd)
730 return True
Ryan Cuifa55df52011-05-06 11:16:55 -0700731
Ryan Cui9b651632011-05-11 11:38:58 -0700732 hooks = _get_project_hooks(project)
Ryan Cui1562fb82011-05-09 11:01:31 -0700733 error_found = False
Ryan Cuifa55df52011-05-06 11:16:55 -0700734 for commit in commit_list:
Ryan Cui1562fb82011-05-09 11:01:31 -0700735 error_list = []
Ryan Cui9b651632011-05-11 11:38:58 -0700736 for hook in hooks:
Ryan Cui1562fb82011-05-09 11:01:31 -0700737 hook_error = hook(project, commit)
738 if hook_error:
739 error_list.append(hook_error)
740 error_found = True
741 if error_list:
742 PrintErrorsForCommit(project, commit, _get_commit_desc(commit),
743 error_list)
Don Garrettdba548a2011-05-05 15:17:14 -0700744
Ryan Cuiec4d6332011-05-02 14:15:25 -0700745 os.chdir(pwd)
Ryan Cui1562fb82011-05-09 11:01:31 -0700746 return error_found
Mandeep Singh Baines116ad102011-04-27 15:16:37 -0700747
748# Main
Mandeep Singh Baines69e470e2011-04-06 10:34:52 -0700749
Ryan Cui1562fb82011-05-09 11:01:31 -0700750
Anush Elangovan63afad72011-03-23 00:41:27 -0700751def main(project_list, **kwargs):
Doug Anderson06456632012-01-05 11:02:14 -0800752 """Main function invoked directly by repo.
753
754 This function will exit directly upon error so that repo doesn't print some
755 obscure error message.
756
757 Args:
758 project_list: List of projects to run on.
759 kwargs: Leave this here for forward-compatibility.
760 """
Ryan Cui1562fb82011-05-09 11:01:31 -0700761 found_error = False
762 for project in project_list:
Ryan Cui9b651632011-05-11 11:38:58 -0700763 if _run_project_hooks(project):
Ryan Cui1562fb82011-05-09 11:01:31 -0700764 found_error = True
765
766 if (found_error):
767 msg = ('Preupload failed due to errors in project(s). HINTS:\n'
Ryan Cui9b651632011-05-11 11:38:58 -0700768 '- To disable some source style checks, and for other hints, see '
769 '<checkout_dir>/src/repohooks/README\n'
770 '- To upload only current project, run \'repo upload .\'')
Ryan Cui1562fb82011-05-09 11:01:31 -0700771 print >> sys.stderr, msg
Don Garrettdba548a2011-05-05 15:17:14 -0700772 sys.exit(1)
Anush Elangovan63afad72011-03-23 00:41:27 -0700773
Ryan Cui1562fb82011-05-09 11:01:31 -0700774
Doug Anderson44a644f2011-11-02 10:37:37 -0700775def _identify_project(path):
776 """Identify the repo project associated with the given path.
777
778 Returns:
779 A string indicating what project is associated with the path passed in or
780 a blank string upon failure.
781 """
782 return _run_command(['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}'],
783 stderr=subprocess.PIPE, cwd=path).strip()
784
785
786def direct_main(args, verbose=False):
787 """Run hooks directly (outside of the context of repo).
788
789 # Setup for doctests below.
790 # ...note that some tests assume that running pre-upload on this CWD is fine.
791 # TODO: Use mock and actually mock out _run_project_hooks() for tests.
792 >>> mydir = os.path.dirname(os.path.abspath(__file__))
793 >>> olddir = os.getcwd()
794
795 # OK to run w/ no arugments; will run with CWD.
796 >>> os.chdir(mydir)
797 >>> direct_main(['prog_name'], verbose=True)
798 Running hooks on chromiumos/repohooks
799 0
800 >>> os.chdir(olddir)
801
802 # Run specifying a dir
803 >>> direct_main(['prog_name', '--dir=%s' % mydir], verbose=True)
804 Running hooks on chromiumos/repohooks
805 0
806
807 # Not a problem to use a bogus project; we'll just get default settings.
808 >>> direct_main(['prog_name', '--dir=%s' % mydir, '--project=X'],verbose=True)
809 Running hooks on X
810 0
811
812 # Run with project but no dir
813 >>> os.chdir(mydir)
814 >>> direct_main(['prog_name', '--project=X'], verbose=True)
815 Running hooks on X
816 0
817 >>> os.chdir(olddir)
818
819 # Try with a non-git CWD
820 >>> os.chdir('/tmp')
821 >>> direct_main(['prog_name'])
822 Traceback (most recent call last):
823 ...
824 BadInvocation: The current directory is not part of a git project.
825
826 # Check various bad arguments...
827 >>> direct_main(['prog_name', 'bogus'])
828 Traceback (most recent call last):
829 ...
830 BadInvocation: Unexpected arguments: bogus
831 >>> direct_main(['prog_name', '--project=bogus', '--dir=bogusdir'])
832 Traceback (most recent call last):
833 ...
834 BadInvocation: Invalid dir: bogusdir
835 >>> direct_main(['prog_name', '--project=bogus', '--dir=/tmp'])
836 Traceback (most recent call last):
837 ...
838 BadInvocation: Not a git directory: /tmp
839
840 Args:
841 args: The value of sys.argv
842
843 Returns:
844 0 if no pre-upload failures, 1 if failures.
845
846 Raises:
847 BadInvocation: On some types of invocation errors.
848 """
849 desc = 'Run Chromium OS pre-upload hooks on changes compared to upstream.'
850 parser = optparse.OptionParser(description=desc)
851
852 parser.add_option('--dir', default=None,
853 help='The directory that the project lives in. If not '
854 'specified, use the git project root based on the cwd.')
855 parser.add_option('--project', default=None,
856 help='The project repo path; this can affect how the hooks '
857 'get run, since some hooks are project-specific. For '
858 'chromite this is chromiumos/chromite. If not specified, '
859 'the repo tool will be used to figure this out based on '
860 'the dir.')
Doug Anderson14749562013-06-26 13:38:29 -0700861 parser.add_option('--rerun-since', default=None,
862 help='Rerun hooks on old commits since the given date. '
863 'The date should match git log\'s concept of a date. '
864 'e.g. 2012-06-20')
865
866 parser.usage = "pre-upload.py [options] [commits]"
Doug Anderson44a644f2011-11-02 10:37:37 -0700867
868 opts, args = parser.parse_args(args[1:])
869
Doug Anderson14749562013-06-26 13:38:29 -0700870 if opts.rerun_since:
871 if args:
872 raise BadInvocation('Can\'t pass commits and use rerun-since: %s' %
873 ' '.join(args))
874
875 cmd = ['git', 'log', '--since="%s"' % opts.rerun_since, '--pretty=%H']
876 all_commits = _run_command(cmd).splitlines()
877 bot_commits = _run_command(cmd + ['--author=chrome-bot']).splitlines()
878
879 # Eliminate chrome-bot commits but keep ordering the same...
880 bot_commits = set(bot_commits)
881 args = [c for c in all_commits if c not in bot_commits]
882
Doug Anderson44a644f2011-11-02 10:37:37 -0700883
884 # Check/normlaize git dir; if unspecified, we'll use the root of the git
885 # project from CWD
886 if opts.dir is None:
887 git_dir = _run_command(['git', 'rev-parse', '--git-dir'],
888 stderr=subprocess.PIPE).strip()
889 if not git_dir:
890 raise BadInvocation('The current directory is not part of a git project.')
891 opts.dir = os.path.dirname(os.path.abspath(git_dir))
892 elif not os.path.isdir(opts.dir):
893 raise BadInvocation('Invalid dir: %s' % opts.dir)
894 elif not os.path.isdir(os.path.join(opts.dir, '.git')):
895 raise BadInvocation('Not a git directory: %s' % opts.dir)
896
897 # Identify the project if it wasn't specified; this _requires_ the repo
898 # tool to be installed and for the project to be part of a repo checkout.
899 if not opts.project:
900 opts.project = _identify_project(opts.dir)
901 if not opts.project:
902 raise BadInvocation("Repo couldn't identify the project of %s" % opts.dir)
903
904 if verbose:
905 print "Running hooks on %s" % (opts.project)
906
Doug Anderson14749562013-06-26 13:38:29 -0700907 found_error = _run_project_hooks(opts.project, proj_dir=opts.dir,
908 commit_list=args)
Doug Anderson44a644f2011-11-02 10:37:37 -0700909 if found_error:
910 return 1
911 return 0
912
913
914def _test():
915 """Run any built-in tests."""
916 import doctest
917 doctest.testmod()
918
919
Mandeep Singh Baines69e470e2011-04-06 10:34:52 -0700920if __name__ == '__main__':
Doug Anderson44a644f2011-11-02 10:37:37 -0700921 if sys.argv[1:2] == ["--test"]:
922 _test()
923 exit_code = 0
924 else:
925 prog_name = os.path.basename(sys.argv[0])
926 try:
927 exit_code = direct_main(sys.argv)
928 except BadInvocation, e:
929 print >>sys.stderr, "%s: %s" % (prog_name, str(e))
930 exit_code = 1
931 sys.exit(exit_code)