blob: 255ed3a9b3aebc6837f3f49c57572c25df88580a [file] [log] [blame]
agable@chromium.org5a306a22014-02-24 22:13:59 +00001#!/usr/bin/env python
2# Copyright 2014 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""A git command for managing a local cache of git repositories."""
7
szager@chromium.org848fd492014-04-09 19:06:44 +00008from __future__ import print_function
agable@chromium.org5a306a22014-02-24 22:13:59 +00009import errno
10import logging
11import optparse
12import os
szager@chromium.org174766f2014-05-13 21:27:46 +000013import re
agable@chromium.org5a306a22014-02-24 22:13:59 +000014import tempfile
pgervais@chromium.orgf3726102014-04-17 17:24:15 +000015import time
agable@chromium.org5a306a22014-02-24 22:13:59 +000016import subprocess
17import sys
18import urlparse
hinoka@google.com776a2c32014-04-25 07:54:25 +000019import zipfile
agable@chromium.org5a306a22014-02-24 22:13:59 +000020
hinoka@google.com563559c2014-04-02 00:36:24 +000021from download_from_google_storage import Gsutil
agable@chromium.org5a306a22014-02-24 22:13:59 +000022import gclient_utils
23import subcommand
24
szager@chromium.org848fd492014-04-09 19:06:44 +000025try:
26 # pylint: disable=E0602
27 WinErr = WindowsError
28except NameError:
29 class WinErr(Exception):
30 pass
agable@chromium.org5a306a22014-02-24 22:13:59 +000031
32class LockError(Exception):
33 pass
34
35
36class Lockfile(object):
37 """Class to represent a cross-platform process-specific lockfile."""
38
39 def __init__(self, path):
40 self.path = os.path.abspath(path)
41 self.lockfile = self.path + ".lock"
42 self.pid = os.getpid()
43
44 def _read_pid(self):
45 """Read the pid stored in the lockfile.
46
47 Note: This method is potentially racy. By the time it returns the lockfile
48 may have been unlocked, removed, or stolen by some other process.
49 """
50 try:
51 with open(self.lockfile, 'r') as f:
52 pid = int(f.readline().strip())
53 except (IOError, ValueError):
54 pid = None
55 return pid
56
57 def _make_lockfile(self):
58 """Safely creates a lockfile containing the current pid."""
59 open_flags = (os.O_CREAT | os.O_EXCL | os.O_WRONLY)
60 fd = os.open(self.lockfile, open_flags, 0o644)
61 f = os.fdopen(fd, 'w')
szager@chromium.org848fd492014-04-09 19:06:44 +000062 print(self.pid, file=f)
agable@chromium.org5a306a22014-02-24 22:13:59 +000063 f.close()
64
65 def _remove_lockfile(self):
pgervais@chromium.orgf3726102014-04-17 17:24:15 +000066 """Delete the lockfile. Complains (implicitly) if it doesn't exist.
67
68 See gclient_utils.py:rmtree docstring for more explanation on the
69 windows case.
70 """
71 if sys.platform == 'win32':
72 lockfile = os.path.normcase(self.lockfile)
73 for _ in xrange(3):
74 exitcode = subprocess.call(['cmd.exe', '/c',
75 'del', '/f', '/q', lockfile])
76 if exitcode == 0:
77 return
78 time.sleep(3)
79 raise LockError('Failed to remove lock: %s' % lockfile)
80 else:
81 os.remove(self.lockfile)
agable@chromium.org5a306a22014-02-24 22:13:59 +000082
83 def lock(self):
84 """Acquire the lock.
85
86 Note: This is a NON-BLOCKING FAIL-FAST operation.
87 Do. Or do not. There is no try.
88 """
89 try:
90 self._make_lockfile()
91 except OSError as e:
92 if e.errno == errno.EEXIST:
93 raise LockError("%s is already locked" % self.path)
94 else:
95 raise LockError("Failed to create %s (err %s)" % (self.path, e.errno))
96
97 def unlock(self):
98 """Release the lock."""
99 if not self.is_locked():
100 raise LockError("%s is not locked" % self.path)
101 if not self.i_am_locking():
102 raise LockError("%s is locked, but not by me" % self.path)
103 self._remove_lockfile()
104
105 def break_lock(self):
106 """Remove the lock, even if it was created by someone else."""
107 try:
108 self._remove_lockfile()
109 return True
110 except OSError as exc:
111 if exc.errno == errno.ENOENT:
112 return False
113 else:
114 raise
115
116 def is_locked(self):
117 """Test if the file is locked by anyone.
118
119 Note: This method is potentially racy. By the time it returns the lockfile
120 may have been unlocked, removed, or stolen by some other process.
121 """
122 return os.path.exists(self.lockfile)
123
124 def i_am_locking(self):
125 """Test if the file is locked by this process."""
126 return self.is_locked() and self.pid == self._read_pid()
127
128 def __enter__(self):
129 self.lock()
130 return self
131
132 def __exit__(self, *_exc):
szager@chromium.org848fd492014-04-09 19:06:44 +0000133 # Windows is unreliable when it comes to file locking. YMMV.
134 try:
135 self.unlock()
136 except WinErr:
137 pass
agable@chromium.org5a306a22014-02-24 22:13:59 +0000138
139
szager@chromium.org848fd492014-04-09 19:06:44 +0000140class Mirror(object):
141
142 git_exe = 'git.bat' if sys.platform.startswith('win') else 'git'
143 gsutil_exe = os.path.join(
144 os.path.dirname(os.path.abspath(__file__)),
145 'third_party', 'gsutil', 'gsutil')
szager@chromium.org848fd492014-04-09 19:06:44 +0000146
147 def __init__(self, url, refs=None, print_func=None):
148 self.url = url
149 self.refs = refs or []
150 self.basedir = self.UrlToCacheDir(url)
151 self.mirror_path = os.path.join(self.GetCachePath(), self.basedir)
152 self.print = print_func or print
153
hinoka@chromium.orgf8fa23d2014-06-05 01:00:04 +0000154 @property
155 def bootstrap_bucket(self):
156 if 'chrome-internal' in self.url:
157 return 'chrome-git-cache'
158 else:
159 return 'chromium-git-cache'
160
szager@chromium.org174766f2014-05-13 21:27:46 +0000161 @classmethod
162 def FromPath(cls, path):
163 return cls(cls.CacheDirToUrl(path))
164
szager@chromium.org848fd492014-04-09 19:06:44 +0000165 @staticmethod
166 def UrlToCacheDir(url):
167 """Convert a git url to a normalized form for the cache dir path."""
168 parsed = urlparse.urlparse(url)
169 norm_url = parsed.netloc + parsed.path
170 if norm_url.endswith('.git'):
171 norm_url = norm_url[:-len('.git')]
172 return norm_url.replace('-', '--').replace('/', '-').lower()
173
174 @staticmethod
szager@chromium.org174766f2014-05-13 21:27:46 +0000175 def CacheDirToUrl(path):
176 """Convert a cache dir path to its corresponding url."""
177 netpath = re.sub(r'\b-\b', '/', os.path.basename(path)).replace('--', '-')
178 return 'https://%s' % netpath
179
180 @staticmethod
szager@chromium.org848fd492014-04-09 19:06:44 +0000181 def FindExecutable(executable):
182 """This mimics the "which" utility."""
183 path_folders = os.environ.get('PATH').split(os.pathsep)
184
185 for path_folder in path_folders:
186 target = os.path.join(path_folder, executable)
187 # Just incase we have some ~/blah paths.
188 target = os.path.abspath(os.path.expanduser(target))
189 if os.path.isfile(target) and os.access(target, os.X_OK):
190 return target
szager@chromium.org6b5faf52014-04-09 21:54:21 +0000191 if sys.platform.startswith('win'):
192 for suffix in ('.bat', '.cmd', '.exe'):
193 alt_target = target + suffix
szager@chromium.org4039b312014-04-09 21:56:46 +0000194 if os.path.isfile(alt_target) and os.access(alt_target, os.X_OK):
szager@chromium.org6b5faf52014-04-09 21:54:21 +0000195 return alt_target
szager@chromium.org848fd492014-04-09 19:06:44 +0000196 return None
197
198 @classmethod
199 def SetCachePath(cls, cachepath):
200 setattr(cls, 'cachepath', cachepath)
201
202 @classmethod
203 def GetCachePath(cls):
204 if not hasattr(cls, 'cachepath'):
205 try:
206 cachepath = subprocess.check_output(
207 [cls.git_exe, 'config', '--global', 'cache.cachepath']).strip()
208 except subprocess.CalledProcessError:
209 cachepath = None
210 if not cachepath:
211 raise RuntimeError('No global cache.cachepath git configuration found.')
212 setattr(cls, 'cachepath', cachepath)
213 return getattr(cls, 'cachepath')
214
215 def RunGit(self, cmd, **kwargs):
216 """Run git in a subprocess."""
217 cwd = kwargs.setdefault('cwd', self.mirror_path)
218 kwargs.setdefault('print_stdout', False)
219 kwargs.setdefault('filter_fn', self.print)
220 env = kwargs.get('env') or kwargs.setdefault('env', os.environ.copy())
221 env.setdefault('GIT_ASKPASS', 'true')
222 env.setdefault('SSH_ASKPASS', 'true')
223 self.print('running "git %s" in "%s"' % (' '.join(cmd), cwd))
224 gclient_utils.CheckCallAndFilter([self.git_exe] + cmd, **kwargs)
225
226 def config(self, cwd=None):
227 if cwd is None:
228 cwd = self.mirror_path
229 self.RunGit(['config', 'core.deltaBaseCacheLimit',
230 gclient_utils.DefaultDeltaBaseCacheLimit()], cwd=cwd)
231 self.RunGit(['config', 'remote.origin.url', self.url], cwd=cwd)
232 self.RunGit(['config', '--replace-all', 'remote.origin.fetch',
233 '+refs/heads/*:refs/heads/*'], cwd=cwd)
234 for ref in self.refs:
235 ref = ref.lstrip('+').rstrip('/')
236 if ref.startswith('refs/'):
237 refspec = '+%s:%s' % (ref, ref)
238 else:
239 refspec = '+refs/%s/*:refs/%s/*' % (ref, ref)
240 self.RunGit(['config', '--add', 'remote.origin.fetch', refspec], cwd=cwd)
241
242 def bootstrap_repo(self, directory):
hinoka@google.com776a2c32014-04-25 07:54:25 +0000243 """Bootstrap the repo from Google Stroage if possible."""
szager@chromium.org848fd492014-04-09 19:06:44 +0000244
hinoka@google.com776a2c32014-04-25 07:54:25 +0000245 python_fallback = False
246 if sys.platform.startswith('win') and not self.FindExecutable('7z'):
247 python_fallback = True
248 elif sys.platform.startswith('darwin'):
249 # The OSX version of unzip doesn't support zip64.
250 python_fallback = True
251 elif not self.FindExecutable('unzip'):
252 python_fallback = True
szager@chromium.org848fd492014-04-09 19:06:44 +0000253
254 gs_folder = 'gs://%s/%s' % (self.bootstrap_bucket, self.basedir)
hinoka@chromium.org1fddd112014-06-05 20:20:03 +0000255 gsutil = Gsutil(self.gsutil_exe, bypass_prodaccess=True)
szager@chromium.org848fd492014-04-09 19:06:44 +0000256 # Get the most recent version of the zipfile.
257 _, ls_out, _ = gsutil.check_call('ls', gs_folder)
258 ls_out_sorted = sorted(ls_out.splitlines())
259 if not ls_out_sorted:
260 # This repo is not on Google Storage.
261 return False
262 latest_checkout = ls_out_sorted[-1]
263
264 # Download zip file to a temporary directory.
265 try:
266 tempdir = tempfile.mkdtemp()
267 self.print('Downloading %s' % latest_checkout)
268 code, out, err = gsutil.check_call('cp', latest_checkout, tempdir)
269 if code:
270 self.print('%s\n%s' % (out, err))
271 return False
272 filename = os.path.join(tempdir, latest_checkout.split('/')[-1])
273
hinoka@google.com776a2c32014-04-25 07:54:25 +0000274 # Unpack the file with 7z on Windows, unzip on linux, or fallback.
275 if not python_fallback:
276 if sys.platform.startswith('win'):
277 cmd = ['7z', 'x', '-o%s' % directory, '-tzip', filename]
278 else:
279 cmd = ['unzip', filename, '-d', directory]
280 retcode = subprocess.call(cmd)
szager@chromium.org848fd492014-04-09 19:06:44 +0000281 else:
hinoka@google.com776a2c32014-04-25 07:54:25 +0000282 try:
283 with zipfile.ZipFile(filename, 'r') as f:
284 f.printdir()
285 f.extractall(directory)
286 except Exception as e:
287 self.print('Encountered error: %s' % str(e), file=sys.stderr)
288 retcode = 1
289 else:
290 retcode = 0
szager@chromium.org848fd492014-04-09 19:06:44 +0000291 finally:
292 # Clean up the downloaded zipfile.
293 gclient_utils.rmtree(tempdir)
294
295 if retcode:
296 self.print(
297 'Extracting bootstrap zipfile %s failed.\n'
298 'Resuming normal operations.' % filename)
299 return False
300 return True
301
302 def exists(self):
303 return os.path.isfile(os.path.join(self.mirror_path, 'config'))
304
305 def populate(self, depth=None, shallow=False, bootstrap=False,
306 verbose=False):
307 if shallow and not depth:
308 depth = 10000
309 gclient_utils.safe_makedirs(self.GetCachePath())
310
311 v = []
312 if verbose:
313 v = ['-v', '--progress']
314
315 d = []
316 if depth:
317 d = ['--depth', str(depth)]
318
319
320 with Lockfile(self.mirror_path):
321 # Setup from scratch if the repo is new or is in a bad state.
322 tempdir = None
323 if not os.path.exists(os.path.join(self.mirror_path, 'config')):
324 gclient_utils.rmtree(self.mirror_path)
325 tempdir = tempfile.mkdtemp(
326 suffix=self.basedir, dir=self.GetCachePath())
327 bootstrapped = not depth and bootstrap and self.bootstrap_repo(tempdir)
328 if not bootstrapped:
329 self.RunGit(['init', '--bare'], cwd=tempdir)
330 else:
331 if depth and os.path.exists(os.path.join(self.mirror_path, 'shallow')):
332 logging.warn(
333 'Shallow fetch requested, but repo cache already exists.')
334 d = []
335
336 rundir = tempdir or self.mirror_path
337 self.config(rundir)
338 fetch_cmd = ['fetch'] + v + d + ['origin']
339 fetch_specs = subprocess.check_output(
340 [self.git_exe, 'config', '--get-all', 'remote.origin.fetch'],
341 cwd=rundir).strip().splitlines()
342 for spec in fetch_specs:
343 try:
344 self.RunGit(fetch_cmd + [spec], cwd=rundir, retry=True)
345 except subprocess.CalledProcessError:
346 logging.warn('Fetch of %s failed' % spec)
347 if tempdir:
348 os.rename(tempdir, self.mirror_path)
349
350 def update_bootstrap(self):
351 # The files are named <git number>.zip
352 gen_number = subprocess.check_output(
353 [self.git_exe, 'number', 'master'], cwd=self.mirror_path).strip()
354 self.RunGit(['gc']) # Run Garbage Collect to compress packfile.
355 # Creating a temp file and then deleting it ensures we can use this name.
356 _, tmp_zipfile = tempfile.mkstemp(suffix='.zip')
357 os.remove(tmp_zipfile)
358 subprocess.call(['zip', '-r', tmp_zipfile, '.'], cwd=self.mirror_path)
359 gsutil = Gsutil(path=self.gsutil_exe, boto_path=None)
360 dest_name = 'gs://%s/%s/%s.zip' % (
361 self.bootstrap_bucket, self.basedir, gen_number)
362 gsutil.call('cp', tmp_zipfile, dest_name)
363 os.remove(tmp_zipfile)
364
szager@chromium.org174766f2014-05-13 21:27:46 +0000365
366 @staticmethod
367 def BreakLocks(path):
368 did_unlock = False
369 lf = Lockfile(path)
370 if lf.break_lock():
371 did_unlock = True
372 # Look for lock files that might have been left behind by an interrupted
373 # git process.
374 lf = os.path.join(path, 'config.lock')
375 if os.path.exists(lf):
376 os.remove(lf)
377 did_unlock = True
378 return did_unlock
379
szager@chromium.org848fd492014-04-09 19:06:44 +0000380 def unlock(self):
szager@chromium.org174766f2014-05-13 21:27:46 +0000381 return self.BreakLocks(self.mirror_path)
382
383 @classmethod
384 def UnlockAll(cls):
385 cachepath = cls.GetCachePath()
386 dirlist = os.listdir(cachepath)
387 repo_dirs = set([os.path.join(cachepath, path) for path in dirlist
388 if os.path.isdir(os.path.join(cachepath, path))])
389 for dirent in dirlist:
390 if (dirent.endswith('.lock') and
391 os.path.isfile(os.path.join(cachepath, dirent))):
392 repo_dirs.add(os.path.join(cachepath, dirent[:-5]))
393
394 unlocked_repos = []
395 for repo_dir in repo_dirs:
396 if cls.BreakLocks(repo_dir):
397 unlocked_repos.append(repo_dir)
398
399 return unlocked_repos
szager@chromium.org848fd492014-04-09 19:06:44 +0000400
agable@chromium.org5a306a22014-02-24 22:13:59 +0000401@subcommand.usage('[url of repo to check for caching]')
402def CMDexists(parser, args):
403 """Check to see if there already is a cache of the given repo."""
szager@chromium.org848fd492014-04-09 19:06:44 +0000404 _, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000405 if not len(args) == 1:
406 parser.error('git cache exists only takes exactly one repo url.')
407 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000408 mirror = Mirror(url)
409 if mirror.exists():
410 print(mirror.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000411 return 0
412 return 1
413
414
hinoka@google.com563559c2014-04-02 00:36:24 +0000415@subcommand.usage('[url of repo to create a bootstrap zip file]')
416def CMDupdate_bootstrap(parser, args):
417 """Create and uploads a bootstrap tarball."""
418 # Lets just assert we can't do this on Windows.
419 if sys.platform.startswith('win'):
szager@chromium.org848fd492014-04-09 19:06:44 +0000420 print('Sorry, update bootstrap will not work on Windows.', file=sys.stderr)
hinoka@google.com563559c2014-04-02 00:36:24 +0000421 return 1
422
423 # First, we need to ensure the cache is populated.
424 populate_args = args[:]
425 populate_args.append('--no_bootstrap')
426 CMDpopulate(parser, populate_args)
427
428 # Get the repo directory.
szager@chromium.org848fd492014-04-09 19:06:44 +0000429 _, args = parser.parse_args(args)
hinoka@google.com563559c2014-04-02 00:36:24 +0000430 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000431 mirror = Mirror(url)
432 mirror.update_bootstrap()
433 return 0
hinoka@google.com563559c2014-04-02 00:36:24 +0000434
435
agable@chromium.org5a306a22014-02-24 22:13:59 +0000436@subcommand.usage('[url of repo to add to or update in cache]')
437def CMDpopulate(parser, args):
438 """Ensure that the cache has all up-to-date objects for the given repo."""
439 parser.add_option('--depth', type='int',
440 help='Only cache DEPTH commits of history')
441 parser.add_option('--shallow', '-s', action='store_true',
442 help='Only cache 10000 commits of history')
443 parser.add_option('--ref', action='append',
444 help='Specify additional refs to be fetched')
hinoka@google.com563559c2014-04-02 00:36:24 +0000445 parser.add_option('--no_bootstrap', action='store_true',
446 help='Don\'t bootstrap from Google Storage')
447
agable@chromium.org5a306a22014-02-24 22:13:59 +0000448 options, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000449 if not len(args) == 1:
450 parser.error('git cache populate only takes exactly one repo url.')
451 url = args[0]
452
szager@chromium.org848fd492014-04-09 19:06:44 +0000453 mirror = Mirror(url, refs=options.ref)
454 kwargs = {
455 'verbose': options.verbose,
456 'shallow': options.shallow,
457 'bootstrap': not options.no_bootstrap,
458 }
agable@chromium.org5a306a22014-02-24 22:13:59 +0000459 if options.depth:
szager@chromium.org848fd492014-04-09 19:06:44 +0000460 kwargs['depth'] = options.depth
461 mirror.populate(**kwargs)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000462
463
464@subcommand.usage('[url of repo to unlock, or -a|--all]')
465def CMDunlock(parser, args):
466 """Unlock one or all repos if their lock files are still around."""
467 parser.add_option('--force', '-f', action='store_true',
468 help='Actually perform the action')
469 parser.add_option('--all', '-a', action='store_true',
470 help='Unlock all repository caches')
471 options, args = parser.parse_args(args)
472 if len(args) > 1 or (len(args) == 0 and not options.all):
473 parser.error('git cache unlock takes exactly one repo url, or --all')
474
agable@chromium.org5a306a22014-02-24 22:13:59 +0000475 if not options.force:
szager@chromium.org174766f2014-05-13 21:27:46 +0000476 cachepath = Mirror.GetCachePath()
477 lockfiles = [os.path.join(cachepath, path)
478 for path in os.listdir(cachepath)
479 if path.endswith('.lock') and os.path.isfile(path)]
agable@chromium.org5a306a22014-02-24 22:13:59 +0000480 parser.error('git cache unlock requires -f|--force to do anything. '
481 'Refusing to unlock the following repo caches: '
482 ', '.join(lockfiles))
483
szager@chromium.org848fd492014-04-09 19:06:44 +0000484 unlocked_repos = []
szager@chromium.org174766f2014-05-13 21:27:46 +0000485 if options.all:
486 unlocked_repos.extend(Mirror.UnlockAll())
487 else:
488 m = Mirror(args[0])
489 if m.unlock():
490 unlocked_repos.append(m.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000491
szager@chromium.org848fd492014-04-09 19:06:44 +0000492 if unlocked_repos:
493 logging.info('Broke locks on these caches:\n %s' % '\n '.join(
494 unlocked_repos))
agable@chromium.org5a306a22014-02-24 22:13:59 +0000495
496
497class OptionParser(optparse.OptionParser):
498 """Wrapper class for OptionParser to handle global options."""
499
500 def __init__(self, *args, **kwargs):
501 optparse.OptionParser.__init__(self, *args, prog='git cache', **kwargs)
502 self.add_option('-c', '--cache-dir',
503 help='Path to the directory containing the cache')
szager@chromium.org2c391af2014-05-23 09:07:15 +0000504 self.add_option('-v', '--verbose', action='count', default=1,
agable@chromium.org5a306a22014-02-24 22:13:59 +0000505 help='Increase verbosity (can be passed multiple times)')
szager@chromium.org2c391af2014-05-23 09:07:15 +0000506 self.add_option('-q', '--quiet', action='store_true',
507 help='Suppress all extraneous output')
agable@chromium.org5a306a22014-02-24 22:13:59 +0000508
509 def parse_args(self, args=None, values=None):
510 options, args = optparse.OptionParser.parse_args(self, args, values)
szager@chromium.org2c391af2014-05-23 09:07:15 +0000511 if options.quiet:
512 options.verbose = 0
513
514 levels = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
515 logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
agable@chromium.org5a306a22014-02-24 22:13:59 +0000516
517 try:
szager@chromium.org848fd492014-04-09 19:06:44 +0000518 global_cache_dir = Mirror.GetCachePath()
519 except RuntimeError:
520 global_cache_dir = None
521 if options.cache_dir:
522 if global_cache_dir and (
523 os.path.abspath(options.cache_dir) !=
524 os.path.abspath(global_cache_dir)):
525 logging.warn('Overriding globally-configured cache directory.')
526 Mirror.SetCachePath(options.cache_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000527
agable@chromium.org5a306a22014-02-24 22:13:59 +0000528 return options, args
529
530
531def main(argv):
532 dispatcher = subcommand.CommandDispatcher(__name__)
533 return dispatcher.execute(OptionParser(), argv)
534
535
536if __name__ == '__main__':
537 sys.exit(main(sys.argv[1:]))