blob: 0d0afb60f1c88741671331338d1efbba9e39cd58 [file] [log] [blame]
Mike Frysinger2e65c542016-03-08 16:17:00 -05001#!/usr/bin/python
2# -*- coding:utf-8 -*-
3# Copyright 2016 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Repo pre-upload hook.
18
19Normally this is loaded indirectly by repo itself, but it can be run directly
20when developing.
21"""
22
23from __future__ import print_function
24
25import argparse
Mike Frysinger579111e2019-12-04 21:36:01 -050026import datetime
Mike Frysinger2e65c542016-03-08 16:17:00 -050027import os
28import sys
29
Mike Frysinger7b30f4d2019-10-15 01:18:38 -040030
31# Assert some minimum Python versions as we don't test or support any others.
32# We only support Python 2.7, and require 2.7.5+/3.4+ to include signal fix:
33# https://bugs.python.org/issue14173
34if sys.version_info < (2, 7, 5):
35 print('repohooks: error: Python-2.7.5+ is required', file=sys.stderr)
36 sys.exit(1)
37elif sys.version_info.major == 3 and sys.version_info < (3, 4):
38 # We don't actually test <Python-3.6. Hope for the best!
39 print('repohooks: error: Python-3.4+ is required', file=sys.stderr)
40 sys.exit(1)
Mike Frysingere67d4802020-07-15 00:38:22 -040041elif sys.version_info < (3, 6):
Mike Frysingerd89347c2020-02-14 13:01:44 -050042 # We want to get people off of old versions of Python.
43 print('repohooks: warning: Python-3.6+ is going to be required; '
44 'please upgrade soon to maintain support.', file=sys.stderr)
Mike Frysinger7b30f4d2019-10-15 01:18:38 -040045
46
Mike Frysinger2e65c542016-03-08 16:17:00 -050047_path = os.path.dirname(os.path.realpath(__file__))
48if sys.path[0] != _path:
49 sys.path.insert(0, _path)
50del _path
51
Mike Frysinger2ef213c2017-11-10 15:41:56 -050052# We have to import our local modules after the sys.path tweak. We can't use
53# relative imports because this is an executable program, not a module.
54# pylint: disable=wrong-import-position
Mike Frysingerb9608182016-10-20 20:45:04 -040055import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050056import rh.results
57import rh.config
58import rh.git
59import rh.hooks
Mike Frysingerce3ab292019-08-09 17:58:50 -040060import rh.sixish
Mike Frysinger2e65c542016-03-08 16:17:00 -050061import rh.terminal
62import rh.utils
63
64
65# Repohooks homepage.
66REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
67
68
Josh Gao25abf4b2016-09-23 18:36:27 -070069class Output(object):
70 """Class for reporting hook status."""
71
72 COLOR = rh.terminal.Color()
73 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
74 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
75 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
76 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050077 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070078
Mike Frysinger42234b72019-02-15 16:21:41 -050079 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070080 """Create a new Output object for a specified project.
81
82 Args:
83 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070084 """
85 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050086 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070087 self.hook_index = 0
88 self.success = True
Mike Frysinger579111e2019-12-04 21:36:01 -050089 self.start_time = datetime.datetime.now()
Josh Gao25abf4b2016-09-23 18:36:27 -070090
Mike Frysinger42234b72019-02-15 16:21:41 -050091 def set_num_hooks(self, num_hooks):
92 """Keep track of how many hooks we'll be running.
93
94 Args:
95 num_hooks: number of hooks to be run.
96 """
97 self.num_hooks = num_hooks
98
Josh Gao25abf4b2016-09-23 18:36:27 -070099 def commit_start(self, commit, commit_summary):
100 """Emit status for new commit.
101
102 Args:
103 commit: commit hash.
104 commit_summary: commit summary.
105 """
106 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
107 rh.terminal.print_status_line(status_line, print_newline=True)
108 self.hook_index = 1
109
110 def hook_start(self, hook_name):
111 """Emit status before the start of a hook.
112
113 Args:
114 hook_name: name of the hook.
115 """
116 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
117 self.num_hooks, hook_name)
118 self.hook_index += 1
119 rh.terminal.print_status_line(status_line)
120
121 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500122 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700123
124 Args:
125 hook_name: name of the hook.
126 error: error string.
127 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500128 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700129
Jason Monk0886c912017-11-10 13:17:17 -0500130 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500131 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500132
133 Args:
134 hook_name: name of the hook.
135 warning: warning string.
136 """
137 status_line = '[%s] %s' % (self.WARNING, hook_name)
138 rh.terminal.print_status_line(status_line, print_newline=True)
139 print(warning, file=sys.stderr)
140
Mike Frysingera18d5f12019-02-15 16:27:35 -0500141 def error(self, header, error):
142 """Print a general error.
143
144 Args:
145 header: A unique identifier for the source of this error.
146 error: error string.
147 """
148 status_line = '[%s] %s' % (self.FAILED, header)
149 rh.terminal.print_status_line(status_line, print_newline=True)
150 print(error, file=sys.stderr)
151 self.success = False
152
Josh Gao25abf4b2016-09-23 18:36:27 -0700153 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500154 """Print summary for all the hooks."""
Mike Frysinger579111e2019-12-04 21:36:01 -0500155 status_line = '[%s] repohooks for %s %s in %s' % (
Josh Gao25abf4b2016-09-23 18:36:27 -0700156 self.PASSED if self.success else self.FAILED,
157 self.project_name,
Mike Frysinger579111e2019-12-04 21:36:01 -0500158 'passed' if self.success else 'failed',
159 rh.utils.timedelta_str(datetime.datetime.now() - self.start_time))
Josh Gao25abf4b2016-09-23 18:36:27 -0700160 rh.terminal.print_status_line(status_line, print_newline=True)
161
162
163def _process_hook_results(results):
164 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500165
166 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700167 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500168
169 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700170 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500171 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500172 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700173 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500174 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500175
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400176 # We track these as dedicated fields in case a hook doesn't output anything.
177 # We want to treat silent non-zero exits as failures too.
178 has_error = False
179 has_warning = False
180
Jason Monk0886c912017-11-10 13:17:17 -0500181 error_ret = ''
182 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500183 for result in results:
184 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500185 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500186 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700187 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500188 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700189 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500190 if result.is_warning():
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400191 has_warning = True
Jason Monk0886c912017-11-10 13:17:17 -0500192 warning_ret += ret
193 else:
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400194 has_error = True
Jason Monk0886c912017-11-10 13:17:17 -0500195 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500196
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400197 return (error_ret if has_error else None,
198 warning_ret if has_warning else None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500199
200
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700201def _get_project_config():
202 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500203
204 Expects to be called from within the project root.
205 """
Mike Frysingerca797702016-09-03 02:00:55 -0400206 global_paths = (
207 # Load the global config found in the manifest repo.
208 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
209 # Load the global config found in the root of the repo checkout.
210 rh.git.find_repo_root(),
211 )
212 paths = (
213 # Load the config for this git repo.
214 '.',
215 )
Mike Frysinger828a0ee2019-08-05 17:42:04 -0400216 return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500217
218
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800219def _attempt_fixes(fixup_func_list, commit_list):
220 """Attempts to run |fixup_func_list| given |commit_list|."""
221 if len(fixup_func_list) != 1:
222 # Only single fixes will be attempted, since various fixes might
223 # interact with each other.
224 return
225
226 hook_name, commit, fixup_func = fixup_func_list[0]
227
228 if commit != commit_list[0]:
229 # If the commit is not at the top of the stack, git operations might be
230 # needed and might leave the working directory in a tricky state if the
231 # fix is attempted to run automatically (e.g. it might require manual
232 # merge conflict resolution). Refuse to run the fix in those cases.
233 return
234
235 prompt = ('An automatic fix can be attempted for the "%s" hook. '
236 'Do you want to run it?' % hook_name)
237 if not rh.terminal.boolean_prompt(prompt):
238 return
239
240 result = fixup_func()
241 if result:
242 print('Attempt to fix "%s" for commit "%s" failed: %s' %
243 (hook_name, commit, result),
244 file=sys.stderr)
245 else:
246 print('Fix successfully applied. Amend the current commit before '
247 'attempting to upload again.\n', file=sys.stderr)
248
249
Mike Frysinger42234b72019-02-15 16:21:41 -0500250def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
251 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500252
253 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500254 project_name: The name of this project.
255 proj_dir: The directory for this project (for passing on in metadata).
256 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500257 commit_list: A list of commits to run hooks against. If None or empty
258 list then we'll automatically get the list of commits that would be
259 uploaded.
260
261 Returns:
262 False if any errors were found, else True.
263 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500264 try:
265 config = _get_project_config()
266 except rh.config.ValidationError as e:
267 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500268 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500269
270 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700271 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400272 if not hooks:
273 return True
274
Mike Frysinger42234b72019-02-15 16:21:41 -0500275 output.set_num_hooks(len(hooks))
276
Mike Frysinger2e65c542016-03-08 16:17:00 -0500277 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700278 try:
279 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800280 upstream_branch = rh.git.get_upstream_branch()
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500281 except rh.utils.CalledProcessError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500282 output.error('Upstream remote/tracking branch lookup',
283 '%s\nDid you run repo start? Is your HEAD detached?' %
284 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500285 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500286
Mike Frysinger2e65c542016-03-08 16:17:00 -0500287 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800288 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysingerfdd1a842019-08-09 13:45:57 -0400289 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800290 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500291 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800292 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500293 })
294
Mike Frysingerb9608182016-10-20 20:45:04 -0400295 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500296
297 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700298 commit_list = rh.git.get_commits(
299 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500300
Mike Frysinger2e65c542016-03-08 16:17:00 -0500301 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800302 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700303
Mike Frysinger2e65c542016-03-08 16:17:00 -0500304 for commit in commit_list:
305 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400306 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500307 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400308 desc = rh.git.get_commit_desc(commit)
Mike Frysingerce3ab292019-08-09 17:58:50 -0400309 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500310
Josh Gao25abf4b2016-09-23 18:36:27 -0700311 commit_summary = desc.split('\n', 1)[0]
312 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500313
Josh Gao25abf4b2016-09-23 18:36:27 -0700314 for name, hook in hooks:
315 output.hook_start(name)
316 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500317 (error, warning) = _process_hook_results(hook_results)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400318 if error is not None or warning is not None:
319 if warning is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500320 output.hook_warning(name, warning)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400321 if error is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500322 ret = False
323 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800324 for result in hook_results:
325 if result.fixup_func:
326 fixup_func_list.append((name, commit,
327 result.fixup_func))
328
329 if fixup_func_list:
330 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700331
Mike Frysinger2e65c542016-03-08 16:17:00 -0500332 return ret
333
334
Mike Frysinger42234b72019-02-15 16:21:41 -0500335def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
336 """Run the project-specific hooks in |proj_dir|.
337
338 Args:
339 project_name: The name of project to run hooks for.
340 proj_dir: If non-None, this is the directory the project is in. If None,
341 we'll ask repo.
342 commit_list: A list of commits to run hooks against. If None or empty
343 list then we'll automatically get the list of commits that would be
344 uploaded.
345
346 Returns:
347 False if any errors were found, else True.
348 """
349 output = Output(project_name)
350
351 if proj_dir is None:
352 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500353 result = rh.utils.run(cmd, capture_output=True)
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500354 proj_dirs = result.stdout.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400355 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500356 print('%s cannot be found.' % project_name, file=sys.stderr)
357 print('Please specify a valid project.', file=sys.stderr)
358 return False
359 if len(proj_dirs) > 1:
360 print('%s is associated with multiple directories.' % project_name,
361 file=sys.stderr)
362 print('Please specify a directory to help disambiguate.',
363 file=sys.stderr)
364 return False
365 proj_dir = proj_dirs[0]
366
367 pwd = os.getcwd()
368 try:
369 # Hooks assume they are run from the root of the project.
370 os.chdir(proj_dir)
371 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
372 commit_list=commit_list)
373 finally:
374 output.finish()
375 os.chdir(pwd)
376
377
Mike Frysinger2e65c542016-03-08 16:17:00 -0500378def main(project_list, worktree_list=None, **_kwargs):
379 """Main function invoked directly by repo.
380
381 We must use the name "main" as that is what repo requires.
382
383 This function will exit directly upon error so that repo doesn't print some
384 obscure error message.
385
386 Args:
387 project_list: List of projects to run on.
388 worktree_list: A list of directories. It should be the same length as
389 project_list, so that each entry in project_list matches with a
390 directory in worktree_list. If None, we will attempt to calculate
391 the directories automatically.
392 kwargs: Leave this here for forward-compatibility.
393 """
394 found_error = False
395 if not worktree_list:
396 worktree_list = [None] * len(project_list)
397 for project, worktree in zip(project_list, worktree_list):
398 if not _run_project_hooks(project, proj_dir=worktree):
399 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500400 # If a repo had failures, add a blank line to help break up the
401 # output. If there were no failures, then the output should be
402 # very minimal, so we don't add it then.
403 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500404
405 if found_error:
406 color = rh.terminal.Color()
407 print('%s: Preupload failed due to above error(s).\n'
408 'For more info, please see:\n%s' %
409 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
410 file=sys.stderr)
411 sys.exit(1)
412
413
414def _identify_project(path):
415 """Identify the repo project associated with the given path.
416
417 Returns:
418 A string indicating what project is associated with the path passed in or
419 a blank string upon failure.
420 """
421 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400422 return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500423
424
425def direct_main(argv):
426 """Run hooks directly (outside of the context of repo).
427
428 Args:
429 argv: The command line args to process.
430
431 Returns:
432 0 if no pre-upload failures, 1 if failures.
433
434 Raises:
435 BadInvocation: On some types of invocation errors.
436 """
437 parser = argparse.ArgumentParser(description=__doc__)
438 parser.add_argument('--dir', default=None,
439 help='The directory that the project lives in. If not '
440 'specified, use the git project root based on the cwd.')
441 parser.add_argument('--project', default=None,
442 help='The project repo path; this can affect how the '
443 'hooks get run, since some hooks are project-specific.'
444 'If not specified, `repo` will be used to figure this '
445 'out based on the dir.')
446 parser.add_argument('commits', nargs='*',
447 help='Check specific commits')
448 opts = parser.parse_args(argv)
449
450 # Check/normalize git dir; if unspecified, we'll use the root of the git
451 # project from CWD.
452 if opts.dir is None:
453 cmd = ['git', 'rev-parse', '--git-dir']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400454 git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500455 if not git_dir:
456 parser.error('The current directory is not part of a git project.')
457 opts.dir = os.path.dirname(os.path.abspath(git_dir))
458 elif not os.path.isdir(opts.dir):
459 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100460 elif not rh.git.is_git_repository(opts.dir):
461 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500462
463 # Identify the project if it wasn't specified; this _requires_ the repo
464 # tool to be installed and for the project to be part of a repo checkout.
465 if not opts.project:
466 opts.project = _identify_project(opts.dir)
467 if not opts.project:
468 parser.error("Repo couldn't identify the project of %s" % opts.dir)
469
470 if _run_project_hooks(opts.project, proj_dir=opts.dir,
471 commit_list=opts.commits):
472 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400473 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500474
475
476if __name__ == '__main__':
477 sys.exit(direct_main(sys.argv[1:]))