#!/usr/bin/python3 import argparse, binascii, configparser, email.message, git, io, logging, math, os import os.path, re, subprocess, sys, yaml def read_dotconfig(): xdg_config_home = os.path.join(os.path.expanduser('~'), '.config') filename = os.path.join(xdg_config_home, 'tms', 'config') config = configparser.ConfigParser() try: with io.open(filename, 'r') as fobj: config.read_file(fobj) return config except: return None class fragile(object): class Break(Exception): '''Break out of the with statement''' def __init__(self, value): self.value = value def __enter__(self): return self.value.__enter__() def __exit__(self, etype, value, traceback): error = self.value.__exit__(etype, value, traceback) if etype == self.Break: return True return error class Log: ESC = '\033' CSI = ESC + '[' RESET = CSI + 'm' COLOR_NONE = '30' COLOR_RED = '31' COLOR_GREEN = '32' COLOR_YELLOW = '33' COLOR_BLUE = '34' COLOR_MAGENTA = '35' STYLE_NONE = '0' STYLE_BOLD = '1' STYLE_DIM = '2' STYLE_ITALIC = '3' STYLE_UNDERLINE = '4' def __init__(self, colorize = True): self.color = Log.COLOR_NONE self.style = Log.STYLE_NONE self.colorize = colorize self.stack = [] def push(self, obj, color = COLOR_NONE, style = STYLE_NONE): if not self.colorize: color = None style = None if color is None and style is None: return str(obj) self.stack.append((self.color, self.style)) self.color = color self.style = style return Log.CSI + self.style + ';' + self.color + 'm' + str(obj) def pop(self, obj = None): if not self.colorize: return str(obj) if self.stack: self.color, self.style = self.stack.pop() else: print('ERROR: unbalanced pop()') self.color = Log.COLOR_NONE self.style = Log.STYLE_NONE if obj is not None: return Log.CSI + self.style + ';' + self.color + 'm' + str(obj) def wrap(self, obj, color = COLOR_NONE, style = STYLE_NONE): if not self.colorize: color = None style = None if color is None and style is None: return str(obj) return Log.CSI + style + ';' + color + 'm' + str(obj) + Log.RESET def red(self, obj, push = False, style = STYLE_NONE): if push: return self.push(obj, Log.COLOR_RED, style) else: return self.wrap(obj, Log.COLOR_RED, style) def green(self, obj, push = False, style = STYLE_NONE): if push: return self.push(obj, Log.COLOR_GREEN, style) else: return self.wrap(obj, Log.COLOR_GREEN, style) def yellow(self, obj, push = False, style = STYLE_NONE): if push: return self.push(obj, Log.COLOR_YELLOW, style) else: return self.wrap(obj, Log.COLOR_YELLOW, style) def yellow(self, obj, push = False, style = STYLE_NONE): if push: return self.push(obj, Log.COLOR_YELLOW, style) else: return self.wrap(obj, Log.COLOR_YELLOW, style) def blue(self, obj, push = False, style = STYLE_NONE): if push: return self.push(obj, Log.COLOR_BLUE, style) else: return self.wrap(obj, Log.COLOR_BLUE, style) def magenta(self, obj, push = False, style = STYLE_NONE): if push: return self.push(obj, Log.COLOR_MAGENTA, style) else: return self.wrap(obj, Log.COLOR_MAGENTA, style) class CrossCompiler: def __init__(self, arch): self.prefix = None self.path = None filename = os.path.expanduser('~/.cross-compile') with open(filename, 'r') as fobj: for line in fobj: if line.startswith('#'): continue key, value = line.split(':', 1) if key == 'path': value = os.path.expandvars(value.strip()) if self.path: self.path = ':'.join([self.path, value]) else: self.path = value continue if key == arch: self.prefix = value.strip() self.arch = key break else: raise Exception('foobar') class Remote: def __init__(self, name, data): self.name = name self.push = None self.pull = None if 'push' in data: self.push = data['push'] if 'pull' in data: self.pull = data['pull'] def __str__(self): return self.name def dump(self, indent = 0, output = sys.stdout): prefix = ' ' * indent print('%s%s: %s' % (prefix, self.name, self.url), file = output) class Target: def __init__(self, name, data): self.name = name self.prefix = None self.data = data if 'tag-prefix' in data: self.prefix = data['tag-prefix'] else: self.prefix = '' if 'from' in data: self.author = data['from'] else: self.author = None if 'to' in data: self.to = data['to'] else: self.to = [] if 'cc' in data: self.cc = data['cc'] else: self.cc = [] if 'addressee' in data: self.addressee = data['addressee'] else: self.addressee = None def __str__(self): return self.name def dump(self, indent = 0, output = sys.stdout): prefix = ' ' * indent print('%s%s:' % (prefix, self.name), file = output) class Branch: def __init__(self, tree, name, data): self.tree = tree self.name = name self.data = data self.remote = None self.target = None self.merged = False self.branches = [] if 'merged' in data: self.merged = data['merged'] if 'remote' in data: self.remote = tree.find_remote(data['remote']) if 'target' in data: self.target = tree.find_target(data['target']) if 'dependencies' in data: for name, branch in data['dependencies'].items(): branch = Branch(self.tree, name, branch) self.branches.append(branch) def __iter__(self): return iter(self.branches) def __str__(self): return self.name def build(self, repo, build, source, output, jobs, log, verbose = False, checks = 0, warnings = 0): arch, config = build.name.split('/') build_log = os.path.join(output, 'build.log') extra_args = [] if not verbose: print('building %s for %s...' % (log.magenta(self), log.blue(build)), end = '') sys.stdout.flush() else: print('building %s for %s...' % (log.magenta(self), log.blue(build))) print(' jobs: %u' % jobs) print(' output: %s' % output) print(' architecture: %s' % arch) print(' configuration: %s' % config) os.makedirs(output, exist_ok = True) cross_compile = CrossCompiler(arch) env = os.environ.copy() path = ':'.join([env['PATH'], cross_compile.path]) env.update({ 'ARCH': cross_compile.arch, 'CROSS_COMPILE': cross_compile.prefix, 'KBUILD_OUTPUT': output, 'PATH': path, }) # if we're acting on a worktree, make sure to use the worktree as the # source directory for the builds if source: extra_args += [ '-C', source ] if checks: extra_args += [ 'C=%u' % checks ] if warnings: extra_args += [ 'W=%u' % warnings ] # check out the base branch base = self.branches[0] if source: repo.git.reset('--hard', base.name) else: repo.git.checkout(base.name) # ... and build it with fragile(open(build_log, 'w')) as fobj: cmd = ['make', *extra_args, '-j%u' % args.jobs, config ] if verbose: print(' environment:', env) print(' command:', cmd) complete = subprocess.run(cmd, env = env, stdout = fobj, stderr = subprocess.STDOUT) if complete.returncode != 0: raise fragile.Break cmd = ['make', *extra_args, '-j%u' % args.jobs ] complete = subprocess.run(cmd, env = env, stdout = fobj, stderr = subprocess.STDOUT) if complete.returncode != 0: raise fragile.Break # check out the branch and build it to get a more sensible # warnings/checks diff if source: repo.git.reset('--hard', self.name) else: repo.git.checkout(self.name) with fragile(open(build_log, 'w')) as fobj: cmd = ['make', *extra_args, '-j%u' % args.jobs, config ] if verbose: print(' environment:', env) print(' command:', cmd) complete = subprocess.run(cmd, env = env, stdout = fobj, stderr = subprocess.STDOUT) if complete.returncode != 0: raise fragile.Break cmd = ['make', *extra_args, '-j%u' % args.jobs ] complete = subprocess.run(cmd, env = env, stdout = fobj, stderr = subprocess.STDOUT) if complete.returncode != 0: raise fragile.Break if complete.returncode == 0: print(log.green('done')) else: print(log.red('failed')) def check_trailers(self, repo, commit, log): def identity(actor): return '%s <%s>' % (actor.name, actor.email) def sign_off(actor): return 'Signed-off-by: %s <%s>' % (actor.name, actor.email) with repo.config_reader() as config: abbrev = config.get_value('core', 'abbrev', 12) signoffs = { 'committer': { 'identity': identity(commit.committer), 'present': False, }, 'author': { 'identity': identity(commit.author), 'present': False, }, } # skip merge commits if len(commit.parents) > 1: return committer = identity(commit.committer) author = identity(commit.author) proc = repo.git.execute(['git', 'interpret-trailers', '--parse'], as_process = True, istream = subprocess.PIPE) stdout, _ = proc.communicate(str(commit.message).encode()) for trailer in stdout.decode().splitlines(): key, value = map(lambda x: x.strip(), trailer.split(':', 1)) if key == 'Signed-off-by': if value == committer: signoffs['committer']['present'] = True if value == author: signoffs['author']['present'] = True hexsha = binascii.hexlify(commit.binsha).decode()[0:abbrev] for key, value in signoffs.items(): if not value['present']: print('%s: commit %s ("%s") in branch %s' % (log.red('ERROR', Log.STYLE_BOLD), hexsha, commit.summary, self)) print('%s is missing a Signed-off-by: from its %s %s' % (' ' * 5, key, value['identity'])) def check_references(self, repo, commit, log): with repo.config_reader() as config: abbrev = config.get_value('core', 'abbrev', 12) proc = repo.git.execute(['git', 'interpret-trailers', '--parse'], as_process = True, istream = subprocess.PIPE) stdout, _ = proc.communicate(str(commit.message).encode()) trailers = [] for trailer in stdout.decode().splitlines(): key, value = map(lambda x: x.strip(), trailer.split(':', 1)) if key == 'Fixes': match = re.match('([0-9a-f]+) \("(.*)"\)', value) ref, subject = match.group(1, 2) branches = repo.git.branch('--contains', ref) for branch in branches.splitlines(): match = re.match('\*?\W+(.*)', branch) if self.name == match.group(1): break else: hexsha = binascii.hexlify(commit.binsha).decode()[0:abbrev] print('%s: commit %s ("%s") referenced by' % (log.red('ERROR', style = Log.STYLE_BOLD), log.yellow(ref), subject)) print('%s commit %s ("%s")' % (' ' * 5, log.yellow(hexsha), commit.summary)) print('%s was not found in branch %s' % (' ' * 5, log.green(self))) def check(self, repo, log): rev_list = '%s..%s' % (self.branches[0], self) print('checking branch %s...' % self) for commit in repo.iter_commits(rev_list): self.check_trailers(repo, commit, log) self.check_references(repo, commit, log) def checkout(self, repo, dry_run = False): print('checking out %s...' % self.name) if not dry_run: repo.git.checkout(self.name) #branch = repo.refs[self.name] #repo.head.reference = branch #repo.head.reset(index = True, working_tree = True) def filter(self, remotes = []): branches = [] if remotes: for branch in self.branches: if branch.remote in remotes: branches.append(branch) return branches def reset(self, repo, branch, dry_run = False): if branch.name in repo.tags: print('resetting branch %s to tag %s...' % (self.name, branch.name)) base = '%s' % branch.name else: print('resetting branch %s to %s...' % (self.name, base)) if branch.remote: base = '%s/%s' % (branch.remote.name, branch.name) else: base = '%s' % branch.name if not dry_run: repo.git.reset(base, hard = True) def merge(self, repo, dry_run = False): base = None self.checkout(repo, dry_run = dry_run) for branch in self.branches: if not base: self.reset(repo, branch, dry_run = dry_run) base = branch print('Merging branch %s into %s' % (branch.name, self.name)) if not dry_run: message = 'Merge branch %s into %s' % (branch.name, self.name) repo.git.merge(branch.name, m = message, no_ff = True) def push(self, repo, push_tags = False, targets = None, iteration = 1, dry_run = False): if not self.remote: raise Exception('cannot push branch %s without remote' % self) remote = self.remote.name branches = [] tags = [] for branch in self.branches: if branch.target and not branch.merged: if targets and branch.target.name not in targets: continue else: continue branches.append('%s' % branch.name) if push_tags: tag_prefix = branch.target.prefix if iteration > 1: suffix = '-v%u' % iteration else: suffix = '' tag = '%s%s%s' % (tag_prefix, branch.name.replace('/', '-'), suffix) tags.append('%s' % tag) branches.append('%s' % self.name) branches.extend(tags) for branch in branches: print(' pushing %s...' % branch) if not dry_run: (status, stdout, stderr) = repo.git.push(remote, branches, with_extended_output = True, with_exceptions = False, dry_run = dry_run, force = True) print(stderr) if status != 0: sys.exit(status) def request_pull(self, repo, targets = None, branches = None, iteration = 1, dry_run = False): git_config = repo.config_reader() config = read_dotconfig() buckets = {} for branch in self.branches: if branch.target and not branch.merged: if targets and branch.target.name not in targets: continue if branches and branch.name not in branches: continue if branch.target not in buckets: buckets[branch.target] = [ ] buckets[branch.target].append(branch) for target, branches in buckets.items(): print('target: %s' % target) if not config: author = (git_config.get('user', 'name'), git_config.get('user', 'email')) else: author = (config.get('user', 'name'), config.get('user', 'email')) author = email.utils.formataddr(author) count = len(branches) for index, branch in enumerate(branches, 1): base = branch.branches[-1].name prefix = branch.target.prefix suffix = '' if iteration > 1: suffix = '-v%u' % iteration release = branch.name.split('/')[0] path = os.path.join('pull-request/%s/%s' % (release, target)) os.makedirs(path, exist_ok = True) tag_name = '%s%s' % (prefix, branch.name.replace('/', '-')) tag = '%s%s' % (tag_name, suffix) print(' requesting pull for %s based on %s' % (tag, base)) # abort early for dry-run if dry_run: return subject = repo.tags[tag].tag.message.splitlines()[0] firstname = author.split()[0] to = ', '.join(target.to) cc = ', '.join(target.cc) if count > 1: # make [GIT PULL index/count] look pretty width = math.ceil(math.log10(count + 1)) if iteration > 1: subject = '[GIT PULL v%u %0*u/%0*u] %s' % (iteration, width, index, width, count, subject) else: subject = '[GIT PULL %0*u/%0*u] %s' % (width, index, width, count, subject) else: if iteration > 1: subject = '[GIT PULL v%u] %s' % (iteration, subject) else: subject = '[GIT PULL] %s' % subject message = email.message.EmailMessage() message['From'] = author message['To'] = to message['Cc'] = cc message['Subject'] = subject if target.addressee: content = 'Hi %s,\n' % target.addressee content += '\n' else: content = 'Hi,\n' content += '\n' pull_request = repo.git.request_pull(base, branch.remote.pull, tag) separator = False prefix = '' for line in pull_request.splitlines(): if line == '-' * 64 and not separator: content += '%sThanks,' % prefix content += '%s%s\n' % (prefix, firstname) separator = True content += '%s%s' % (prefix, line) prefix = '\n' message.set_content(content) if iteration > 1: filename = os.path.join(path, 'v%u-%04u-%s' % (iteration, index, tag_name)) else: filename = os.path.join(path, '%04u-%s' % (index, tag_name)) print('writing message to %s' % filename) with io.open(filename, 'w') as output: print(message, file = output, end = '') def tag(self, repo, prefix, targets = None, branches = None, iteration = 1, dry_run = False): for branch in self.branches: if branch.target and not branch.merged: if targets and branch.target.name not in targets: continue if branches and branch.name not in branches: continue if prefix is None: tag_prefix = branch.target.prefix else: tag_prefix = prefix if iteration > 1: suffix = '-v%u' % iteration else: suffix = '' tag = '%s%s%s' % (tag_prefix, branch.name.replace('/', '-'), suffix) print(' tagging %s as %s' % (branch, tag)) if tag in repo.tags: print('WARNING: tag %s already exists, skipping' % tag) continue # gitpython will always redirect the stdout to a PIPE and the # $EDITOR won't be able to display anything on screen in that # case so call git manually for signed tags. if not dry_run: res = subprocess.call([repo.git.GIT_PYTHON_GIT_EXECUTABLE, 'tag', '--sign', tag, branch.name]) if res != 0: break def dump(self, indent = 0, output = sys.stdout): prefix = ' ' * indent print('%s%s:' % (prefix, self.name), file = output) for dependency in self.branches: dependency.dump(indent + 2, output = output) class Build: def __init__(self, tree, name, data): self.tree = tree self.name = name self.data = data self.branches = [] self.builds = [] if not 'branches' in data: for name, build in data.items(): build = Build(tree, '%s/%s' % (self.name, name), build) self.builds.append(build) else: remotes = [] for remote in data['branches']['remotes']: remote = tree.find_remote(remote) remotes.append(remote) for branch in tree.filter(remotes = remotes): self.branches.append(branch) def __str__(self): return self.name class Tree: def __init__(self, data): self.data = data self.remotes = [] self.targets = [] self.branches = [] self.builds = [] for name, remote in data['remotes'].items(): remote = Remote(name, remote) self.remotes.append(remote) for name, target in data['targets'].items(): target = Target(name, target) self.targets.append(target) for name, branch in data['branches'].items(): branch = Branch(self, name, branch) self.branches.append(branch) for name, build in data['builds'].items(): build = Build(self, name, build) self.builds.extend(build.builds) self.builds.append(build) def __iter__(self): return iter(self.branches) def find_remote(self, name): for remote in self.remotes: if remote.name == name: return remote return None def find_target(self, name): for target in self.targets: if target.name == name: return target return None def dump(self, indent = 0, output = sys.stdout): prefix = ' ' * indent print('%sremotes:' % prefix, file = output) for remote in self.remotes: remote.dump(indent = indent + 2, output = output) print('%stargets:' % prefix, file = output) for target in self.targets: target.dump(indent = indent + 2, output = output) print('%sbranches:' % prefix, file = output) for branch in self.branches: branch.dump(indent = indent + 2, output = output) def filter(self, remotes = []): result = [] if remotes: for branch in self: branches = branch.filter(remotes) result.extend(branches) result.append(branch) return result def load_tree(): topdir = os.path.dirname(sys.argv[0]) filename = os.path.join(topdir, 'tegra-branches.yaml') with io.open(filename, 'r') as fobj: data = yaml.load(fobj, Loader = yaml.SafeLoader) return Tree(data) class Command: @classmethod def setup(cls, parser): if hasattr(cls, 'subcommands'): sub_parsers = parser.add_subparsers(title = 'subcommands') for subcommand in cls.subcommands: sub_parser = sub_parsers.add_parser(subcommand.name, help = subcommand.help) sub_parser.set_defaults(run = subcommand.run) subcommand.setup(sub_parser) @classmethod def run(cls, args): pass class CommandBuild(Command): name = 'build' help = 'build branches' @classmethod def setup(cls, parser): super().setup(parser) parser.add_argument('branches', metavar = 'BRANCH', nargs = '*', help = 'names of branches to build') parser.add_argument('-c', '--color', action = 'store_true', default = True) parser.add_argument('--no-color', action = 'store_false', dest = 'color', help = 'disable log coloring') parser.add_argument('-C', '--checks', action = 'count', help = 'enable checks') parser.add_argument('-j', '--jobs', type = int, default = 1, help = 'number of parallel jobs to run') parser.add_argument('-k', '--keep', action = 'store_true', help = 'do not clean up worktree') parser.add_argument('-o', '--output', help = 'build output directory') parser.add_argument('-v', '--verbose', action = 'store_true', help = 'increase verbosity') parser.add_argument('-w', '--worktree', help = 'worktree directory') parser.add_argument('-W', '--warnings', action = 'count', help = 'enable extra warnings') @classmethod def run(cls, args): log = Log(args.color) repo = git.Repo('.') tree = load_tree() if args.worktree: worktree = os.path.abspath(args.worktree) else: worktree = None if not args.output: if not worktree: output = os.path.join(os.getcwd(), 'build') else: output = os.path.join(worktree, 'build') else: output = os.path.abspath(args.output) if worktree: print('creating worktree %s' % worktree) try: repo.git.worktree('add', '--detach', worktree) except git.exc.GitCommandError as e: # XXX find a better way to deal with this if not args.keep and e.stderr.endswith('already exists'): raise e repo = git.Repo(worktree) print('output directory: %s' % output) for build in tree.builds: parts = build.name.split('/') build_directory = os.path.join(output, *parts) for branch in build.branches: branch_name = '-'.join(branch.name.split('/')) branch_directory = os.path.join(build_directory, branch_name) if args.branches and branch.name not in args.branches: continue branch.build(repo, build, worktree, branch_directory, args.jobs, log, args.verbose, checks = args.checks, warnings = args.warnings) if worktree and not args.keep: repo = git.Repo('.') repo.git.worktree('remove', worktree) class CommandCheck(Command): name = 'check' help = 'check branches' @classmethod def setup(cls, parser): super().setup(parser) parser.add_argument('branches', metavar = 'BRANCH', nargs = '*', help = 'names of branches to check') parser.add_argument('-c', '--color', action = 'store_true', default = True) parser.add_argument('--no-color', action = 'store_false', dest = 'color', help = 'disable log coloring') parser.add_argument('-v', '--verbose', action = 'store_true', help = 'increase verbosity') @classmethod def run(cls, args): log = Log(args.color) repo = git.Repo('.') tree = load_tree() branches = [] for build in tree.builds: for branch in build.branches: if args.branches and branch.name not in args.branches: continue if branch not in branches: branches.append(branch) for branch in branches: branch.check(repo, log) class CommandMerge(Command): name = 'merge' help = 'merge branch' @classmethod def setup(cls, parser): super().setup(parser) parser.add_argument('-n', '--dry-run', action = 'store_true', help = 'display the actions that would be performed') parser.add_argument('branches', metavar = 'BRANCH', nargs = '*', help = 'names of branches to merge') @classmethod def run(cls, args): repo = git.Repo('.') tree = load_tree() for branch in tree: if args.branches and branch.name not in args.branches: continue print('creating branch %s...' % branch) branch.merge(repo, dry_run = args.dry_run) class CommandPush(Command): name = 'push' help = 'push branches' @classmethod def setup(cls, parser): super().setup(parser) parser.add_argument('-n', '--dry-run', action = 'store_true', help = 'do everything except actually send the updates') parser.add_argument('--tags', action = 'store_true', help = 'push tags along with branches') parser.add_argument('-t', '--target', dest = 'targets', action = 'append', type = str, help = 'list of targets for which to push') parser.add_argument('-v', '--reroll-count', dest = 'iteration', action = 'store', type = int, default = 1, help = 'mark the pull-requests as the -th iteration') parser.add_argument('branches', metavar = 'BRANCH', nargs = '*', help = 'names of branches to push') @classmethod def run(cls, args): repo = git.Repo('.') tree = load_tree() for branch in tree: if args.branches and branch.name not in args.branches: continue print('pushing branch %s...' % branch) branch.push(repo, push_tags = args.tags, targets = args.targets, iteration = args.iteration, dry_run = args.dry_run) class CommandRequestPull(Command): name = 'request-pull' help = 'request-pull for a branch' @classmethod def setup(cls, parser): super().setup(parser) parser.add_argument('-n', '--dry-run', action = 'store_true', help = 'display the actions that would be performed') parser.add_argument('-t', '--target', dest = 'targets', action = 'append', type = str, help = 'list of targets for which to request-pull') parser.add_argument('-v', '--reroll-count', dest = 'iteration', action = 'store', type = int, default = 1, help = 'mark the pull-requests as the -th iteration') parser.add_argument('branches', metavar = 'BRANCH', nargs = '*', help = 'names of branches to tag') @classmethod def run(cls, args): repo = git.Repo('.') tree = load_tree() for branch in tree: print('request-pull for %s...' % branch) branch.request_pull(repo, targets = args.targets, branches = args.branches, iteration = args.iteration, dry_run = args.dry_run) class CommandTag(Command): name = 'tag' help = 'tag branches' @classmethod def setup(cls, parser): super().setup(parser) parser.add_argument('-n', '--dry-run', action = 'store_true', help = 'display the actions that would be performed') parser.add_argument('-p', '--prefix', type = str, help = 'prefix to prepend to tag names') parser.add_argument('-t', '--target', dest = 'targets', action = 'append', type = str, help = 'list of targets for which to tag') parser.add_argument('-v', '--reroll-count', dest = 'iteration', action = 'store', type = int, default = 1, help = 'mark the pull-requests as the -th iteration') parser.add_argument('branches', metavar = 'BRANCH', nargs = '*', help = 'names of branches to tag') @classmethod def run(cls, args): repo = git.Repo('.') tree = load_tree() for branch in tree: print('tagging branch %s...' % branch) branch.tag(repo, args.prefix, targets = args.targets, branches = args.branches, iteration = args.iteration, dry_run = args.dry_run) commands = [ CommandBuild, CommandCheck, CommandMerge, CommandPush, CommandRequestPull, CommandTag, ] if __name__ == '__main__': logging.basicConfig(level = logging.INFO) parser = argparse.ArgumentParser() cmd_parsers = parser.add_subparsers(title = 'commands') for cmd in commands: cmd_parser = cmd_parsers.add_parser(cmd.name, help = cmd.help) cmd_parser.set_defaults(run = cmd.run) cmd.setup(cmd_parser) args = parser.parse_args() if not hasattr(args, 'run'): parser.print_help() sys.exit(1) args.run(args)