blob: 086b479b81b5680ce56b6742f6cf17a53b7a2720 [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.orgc2bc22d2014-06-05 21:19:38 +0000255 gsutil = Gsutil(self.gsutil_exe, boto_path=None, 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)
hinoka@chromium.orgc58d11d2014-06-09 23:34:35 +0000268 code = gsutil.call('cp', latest_checkout, tempdir)
szager@chromium.org848fd492014-04-09 19:06:44 +0000269 if code:
szager@chromium.org848fd492014-04-09 19:06:44 +0000270 return False
271 filename = os.path.join(tempdir, latest_checkout.split('/')[-1])
272
hinoka@google.com776a2c32014-04-25 07:54:25 +0000273 # Unpack the file with 7z on Windows, unzip on linux, or fallback.
274 if not python_fallback:
275 if sys.platform.startswith('win'):
276 cmd = ['7z', 'x', '-o%s' % directory, '-tzip', filename]
277 else:
278 cmd = ['unzip', filename, '-d', directory]
279 retcode = subprocess.call(cmd)
szager@chromium.org848fd492014-04-09 19:06:44 +0000280 else:
hinoka@google.com776a2c32014-04-25 07:54:25 +0000281 try:
282 with zipfile.ZipFile(filename, 'r') as f:
283 f.printdir()
284 f.extractall(directory)
285 except Exception as e:
286 self.print('Encountered error: %s' % str(e), file=sys.stderr)
287 retcode = 1
288 else:
289 retcode = 0
szager@chromium.org848fd492014-04-09 19:06:44 +0000290 finally:
291 # Clean up the downloaded zipfile.
292 gclient_utils.rmtree(tempdir)
293
294 if retcode:
295 self.print(
296 'Extracting bootstrap zipfile %s failed.\n'
297 'Resuming normal operations.' % filename)
298 return False
299 return True
300
301 def exists(self):
302 return os.path.isfile(os.path.join(self.mirror_path, 'config'))
303
304 def populate(self, depth=None, shallow=False, bootstrap=False,
305 verbose=False):
306 if shallow and not depth:
307 depth = 10000
308 gclient_utils.safe_makedirs(self.GetCachePath())
309
310 v = []
311 if verbose:
312 v = ['-v', '--progress']
313
314 d = []
315 if depth:
316 d = ['--depth', str(depth)]
317
318
319 with Lockfile(self.mirror_path):
320 # Setup from scratch if the repo is new or is in a bad state.
321 tempdir = None
322 if not os.path.exists(os.path.join(self.mirror_path, 'config')):
323 gclient_utils.rmtree(self.mirror_path)
324 tempdir = tempfile.mkdtemp(
325 suffix=self.basedir, dir=self.GetCachePath())
326 bootstrapped = not depth and bootstrap and self.bootstrap_repo(tempdir)
327 if not bootstrapped:
328 self.RunGit(['init', '--bare'], cwd=tempdir)
329 else:
330 if depth and os.path.exists(os.path.join(self.mirror_path, 'shallow')):
331 logging.warn(
332 'Shallow fetch requested, but repo cache already exists.')
333 d = []
334
335 rundir = tempdir or self.mirror_path
336 self.config(rundir)
337 fetch_cmd = ['fetch'] + v + d + ['origin']
338 fetch_specs = subprocess.check_output(
339 [self.git_exe, 'config', '--get-all', 'remote.origin.fetch'],
340 cwd=rundir).strip().splitlines()
341 for spec in fetch_specs:
342 try:
343 self.RunGit(fetch_cmd + [spec], cwd=rundir, retry=True)
344 except subprocess.CalledProcessError:
345 logging.warn('Fetch of %s failed' % spec)
346 if tempdir:
347 os.rename(tempdir, self.mirror_path)
348
349 def update_bootstrap(self):
350 # The files are named <git number>.zip
351 gen_number = subprocess.check_output(
352 [self.git_exe, 'number', 'master'], cwd=self.mirror_path).strip()
353 self.RunGit(['gc']) # Run Garbage Collect to compress packfile.
354 # Creating a temp file and then deleting it ensures we can use this name.
355 _, tmp_zipfile = tempfile.mkstemp(suffix='.zip')
356 os.remove(tmp_zipfile)
357 subprocess.call(['zip', '-r', tmp_zipfile, '.'], cwd=self.mirror_path)
358 gsutil = Gsutil(path=self.gsutil_exe, boto_path=None)
359 dest_name = 'gs://%s/%s/%s.zip' % (
360 self.bootstrap_bucket, self.basedir, gen_number)
361 gsutil.call('cp', tmp_zipfile, dest_name)
362 os.remove(tmp_zipfile)
363
szager@chromium.org174766f2014-05-13 21:27:46 +0000364
365 @staticmethod
366 def BreakLocks(path):
367 did_unlock = False
368 lf = Lockfile(path)
369 if lf.break_lock():
370 did_unlock = True
371 # Look for lock files that might have been left behind by an interrupted
372 # git process.
373 lf = os.path.join(path, 'config.lock')
374 if os.path.exists(lf):
375 os.remove(lf)
376 did_unlock = True
377 return did_unlock
378
szager@chromium.org848fd492014-04-09 19:06:44 +0000379 def unlock(self):
szager@chromium.org174766f2014-05-13 21:27:46 +0000380 return self.BreakLocks(self.mirror_path)
381
382 @classmethod
383 def UnlockAll(cls):
384 cachepath = cls.GetCachePath()
385 dirlist = os.listdir(cachepath)
386 repo_dirs = set([os.path.join(cachepath, path) for path in dirlist
387 if os.path.isdir(os.path.join(cachepath, path))])
388 for dirent in dirlist:
389 if (dirent.endswith('.lock') and
390 os.path.isfile(os.path.join(cachepath, dirent))):
391 repo_dirs.add(os.path.join(cachepath, dirent[:-5]))
392
393 unlocked_repos = []
394 for repo_dir in repo_dirs:
395 if cls.BreakLocks(repo_dir):
396 unlocked_repos.append(repo_dir)
397
398 return unlocked_repos
szager@chromium.org848fd492014-04-09 19:06:44 +0000399
agable@chromium.org5a306a22014-02-24 22:13:59 +0000400@subcommand.usage('[url of repo to check for caching]')
401def CMDexists(parser, args):
402 """Check to see if there already is a cache of the given repo."""
szager@chromium.org848fd492014-04-09 19:06:44 +0000403 _, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000404 if not len(args) == 1:
405 parser.error('git cache exists only takes exactly one repo url.')
406 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000407 mirror = Mirror(url)
408 if mirror.exists():
409 print(mirror.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000410 return 0
411 return 1
412
413
hinoka@google.com563559c2014-04-02 00:36:24 +0000414@subcommand.usage('[url of repo to create a bootstrap zip file]')
415def CMDupdate_bootstrap(parser, args):
416 """Create and uploads a bootstrap tarball."""
417 # Lets just assert we can't do this on Windows.
418 if sys.platform.startswith('win'):
szager@chromium.org848fd492014-04-09 19:06:44 +0000419 print('Sorry, update bootstrap will not work on Windows.', file=sys.stderr)
hinoka@google.com563559c2014-04-02 00:36:24 +0000420 return 1
421
422 # First, we need to ensure the cache is populated.
423 populate_args = args[:]
424 populate_args.append('--no_bootstrap')
425 CMDpopulate(parser, populate_args)
426
427 # Get the repo directory.
szager@chromium.org848fd492014-04-09 19:06:44 +0000428 _, args = parser.parse_args(args)
hinoka@google.com563559c2014-04-02 00:36:24 +0000429 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000430 mirror = Mirror(url)
431 mirror.update_bootstrap()
432 return 0
hinoka@google.com563559c2014-04-02 00:36:24 +0000433
434
agable@chromium.org5a306a22014-02-24 22:13:59 +0000435@subcommand.usage('[url of repo to add to or update in cache]')
436def CMDpopulate(parser, args):
437 """Ensure that the cache has all up-to-date objects for the given repo."""
438 parser.add_option('--depth', type='int',
439 help='Only cache DEPTH commits of history')
440 parser.add_option('--shallow', '-s', action='store_true',
441 help='Only cache 10000 commits of history')
442 parser.add_option('--ref', action='append',
443 help='Specify additional refs to be fetched')
hinoka@google.com563559c2014-04-02 00:36:24 +0000444 parser.add_option('--no_bootstrap', action='store_true',
445 help='Don\'t bootstrap from Google Storage')
446
agable@chromium.org5a306a22014-02-24 22:13:59 +0000447 options, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000448 if not len(args) == 1:
449 parser.error('git cache populate only takes exactly one repo url.')
450 url = args[0]
451
szager@chromium.org848fd492014-04-09 19:06:44 +0000452 mirror = Mirror(url, refs=options.ref)
453 kwargs = {
454 'verbose': options.verbose,
455 'shallow': options.shallow,
456 'bootstrap': not options.no_bootstrap,
457 }
agable@chromium.org5a306a22014-02-24 22:13:59 +0000458 if options.depth:
szager@chromium.org848fd492014-04-09 19:06:44 +0000459 kwargs['depth'] = options.depth
460 mirror.populate(**kwargs)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000461
462
463@subcommand.usage('[url of repo to unlock, or -a|--all]')
464def CMDunlock(parser, args):
465 """Unlock one or all repos if their lock files are still around."""
466 parser.add_option('--force', '-f', action='store_true',
467 help='Actually perform the action')
468 parser.add_option('--all', '-a', action='store_true',
469 help='Unlock all repository caches')
470 options, args = parser.parse_args(args)
471 if len(args) > 1 or (len(args) == 0 and not options.all):
472 parser.error('git cache unlock takes exactly one repo url, or --all')
473
agable@chromium.org5a306a22014-02-24 22:13:59 +0000474 if not options.force:
szager@chromium.org174766f2014-05-13 21:27:46 +0000475 cachepath = Mirror.GetCachePath()
476 lockfiles = [os.path.join(cachepath, path)
477 for path in os.listdir(cachepath)
478 if path.endswith('.lock') and os.path.isfile(path)]
agable@chromium.org5a306a22014-02-24 22:13:59 +0000479 parser.error('git cache unlock requires -f|--force to do anything. '
480 'Refusing to unlock the following repo caches: '
481 ', '.join(lockfiles))
482
szager@chromium.org848fd492014-04-09 19:06:44 +0000483 unlocked_repos = []
szager@chromium.org174766f2014-05-13 21:27:46 +0000484 if options.all:
485 unlocked_repos.extend(Mirror.UnlockAll())
486 else:
487 m = Mirror(args[0])
488 if m.unlock():
489 unlocked_repos.append(m.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000490
szager@chromium.org848fd492014-04-09 19:06:44 +0000491 if unlocked_repos:
492 logging.info('Broke locks on these caches:\n %s' % '\n '.join(
493 unlocked_repos))
agable@chromium.org5a306a22014-02-24 22:13:59 +0000494
495
496class OptionParser(optparse.OptionParser):
497 """Wrapper class for OptionParser to handle global options."""
498
499 def __init__(self, *args, **kwargs):
500 optparse.OptionParser.__init__(self, *args, prog='git cache', **kwargs)
501 self.add_option('-c', '--cache-dir',
502 help='Path to the directory containing the cache')
szager@chromium.org2c391af2014-05-23 09:07:15 +0000503 self.add_option('-v', '--verbose', action='count', default=1,
agable@chromium.org5a306a22014-02-24 22:13:59 +0000504 help='Increase verbosity (can be passed multiple times)')
szager@chromium.org2c391af2014-05-23 09:07:15 +0000505 self.add_option('-q', '--quiet', action='store_true',
506 help='Suppress all extraneous output')
agable@chromium.org5a306a22014-02-24 22:13:59 +0000507
508 def parse_args(self, args=None, values=None):
509 options, args = optparse.OptionParser.parse_args(self, args, values)
szager@chromium.org2c391af2014-05-23 09:07:15 +0000510 if options.quiet:
511 options.verbose = 0
512
513 levels = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
514 logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
agable@chromium.org5a306a22014-02-24 22:13:59 +0000515
516 try:
szager@chromium.org848fd492014-04-09 19:06:44 +0000517 global_cache_dir = Mirror.GetCachePath()
518 except RuntimeError:
519 global_cache_dir = None
520 if options.cache_dir:
521 if global_cache_dir and (
522 os.path.abspath(options.cache_dir) !=
523 os.path.abspath(global_cache_dir)):
524 logging.warn('Overriding globally-configured cache directory.')
525 Mirror.SetCachePath(options.cache_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000526
agable@chromium.org5a306a22014-02-24 22:13:59 +0000527 return options, args
528
529
530def main(argv):
531 dispatcher = subcommand.CommandDispatcher(__name__)
532 return dispatcher.execute(OptionParser(), argv)
533
534
535if __name__ == '__main__':
536 sys.exit(main(sys.argv[1:]))