From 9c4db9af8c72f7c2488dcf8962fc02eff7d183f7 Mon Sep 17 00:00:00 2001 From: Jason Tibbitts Date: Nov 13 2015 19:29:57 +0000 Subject: Initial commit. --- diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..964123d --- /dev/null +++ b/README.rst @@ -0,0 +1,8 @@ +This is a rewrite of spectool in Python (3.3). + +The initial goal is for it to be completely compatible with the Perl version, +including having identical non-debug non-help output. + +Further goals are to use the Python rpm and curl bindings to avoid shelling out +to curl and rpm, and to eventually handle more advanced situations like +automatically generating git checkouts and validating signatures. diff --git a/spectool b/spectool new file mode 100755 index 0000000..31d44be --- /dev/null +++ b/spectool @@ -0,0 +1,404 @@ +#!/usr/bin/python3 +import argparse +import operator +import re +import sys +from subprocess import CalledProcessError, PIPE, Popen, TimeoutExpired + +# Python conversion of spectool. +# Spectool has two functions: +# Lists source and patche URLs with macros expanded (currently complete!) +# Downloads sources and patches from the expanded URLs. +# XXX Need to do this by shelling out to curl to remain compatible with spectool. +# XXX Maybe only shell out as an option and handle the rest internally? +# XXX Can just parse the curl config file for the one option anyone ever puts there. + +# XXX Some enhancements from https://bugzilla.redhat.com/show_bug.cgi?id=1242988 +# XXX A rather complicated idea at https://bugzilla.redhat.com/show_bug.cgi?id=1093712 + +VERSION = '2.0' +CURLRC = '/etc/rpmdevrools/curlrc' +PROTOCOLS = ['ftp', 'http', 'https'] +dbprint = None + + +# Sure wish I had Python 3.5's subprocess.run(), so here's a hacked one. +class CompletedProcess(object): + """A hacked version of Python 3.5's subprocess.CompletedProcess.""" + def __init__(self, args, returncode, stdout=None, stderr=None): + self.args = args + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class ProcError(CalledProcessError): + """A CalledProcessError that also has stderr and stdout, like py3.5's.""" + def __init__(self, args, returncode, **kwargs): + self.stderr = None + self.stdout = None + if 'stderr' in kwargs: + self.stderr = kwargs['stderr'] + if 'stdout' in kwargs: + self.stdout = kwargs['stdout'] + super().__init__(returncode, args, self.stderr) + + def __str__(self): + out = "Return code {} ".format(self.returncode) + if self.stderr: + out = out + "\nOutput:\n{}".format(self.stderr) + return out + + +def run(*popenargs, timeout=None, **kwargs): + """A hacked version of Python 3.5's subprocess.run() command.""" + kwargs['stdout'] = kwargs['stderr'] = PIPE + + dbprint('Shell: {}'.format(' '.join(*popenargs))) + with Popen(*popenargs, **kwargs) as process: + try: + stdout, stderr = process.communicate(input, timeout=timeout) + except TimeoutExpired: + dbprint('-> Timed out') + process.kill() + stdout, stderr = process.communicate() + raise TimeoutExpired(process.args, timeout, output=stdout, + stderr=stderr) + except: + dbprint('-> Error on call') + process.kill() + process.wait() + raise + retcode = process.poll() + dbprint('=> Return code: {}'.format(retcode)) + stdout = stdout.decode('utf-8') + stderr = stderr.decode('utf-8') + if retcode: + raise ProcError(process.args, retcode, stdout=stdout, stderr=stderr) + return CompletedProcess(process.args, retcode, stdout, stderr) + + +class Spec(object): + """Object to encapsulate a spec file and provide the info we want.""" + interesting_tags = ['source(?P[0-9]*)', 'patch(?P[0-9]*)', 'name', 'version', 'release'] + # Join all of those tags together in a big regex + tag_re = re.compile("^(?P" + "|".join(interesting_tags) + "):\s*(?P.*)\s*$", re.IGNORECASE) + + def __init__(self, spec): + self.spec = spec + self.sources = [] + self.sourcenums = [] + self.patches = [] + self.patchnums = [] + self.epoch = 0 + self.name = self.version = self.release = None + + self.parse() + + def parse(self): + """Call rpmspec and process the result, looking for intersting tags.""" + cmdline = ['rpmspec', '-P', self.spec] + try: + proc = run(cmdline) + except ProcError as e: + print('Error: rpmspec call failed!') + print(e) + sys.exit(1) + for line in proc.stdout.splitlines(): + self.parseline(line) + + def parseline(self, line): + """Parse a spec line looking for interesting tags.""" + m = self.tag_re.match(line) + if not m: + return + + tag = m.group('tag').lower() + val = m.group('val') + if tag == 'name': + self.name = val + elif tag == 'version': + self.version = val + elif tag == 'release': + self.release = val + elif tag == 'epoch': + self.epoch = val + elif tag.startswith('source'): + self.sources.append(val) + self.sourcenums.append(int(m.group('snum') or 0)) + elif tag.startswith('patch'): + self.patches.append(val) + self.patchnums.append(int(m.group('pnum') or 0)) + + +class SelectionError(Exception): + pass + + +class Selections(object): + """Quick class to handle the sources and patches the user wants.""" + def __init__(self, spec, options): + self.allsources = spec.sources + self.allsourcenums = spec.sourcenums + self.sourcenums = [] + self.sources = [] + + self.allpatchnums = spec.patchnums + self.allpatches = spec.patches + self.patchnums = [] + self.patches = [] + + self.get_selected_items_from_options(options) + + def get_selected_items_from_options(self, opts): + """Get the lists of sources and patches the user selected.""" + if opts.allsources or opts.all: + for source, num in sorted(zip(self.allsources, self.allsourcenums), key=operator.itemgetter(1)): + self.sourcenums.append(num) + self.sources.append(source) + + if opts.allpatches or opts.all: + for patch, num in sorted(zip(self.allpatches, self.allpatchnums), key=operator.itemgetter(1)): + self.patchnums.append(num) + self.patches.append(patch) + + for source in opts.sourcelist: + if source in self.allsourcenums: + self.sourcenums.append(source) + self.sources.append(self.allsources[source]) + else: + raise SelectionError('No source item {}.'.format(source)) + + for patch in opts.patchlist: + if patch in self.allpatchnums: + self.patchnums.append(patch) + self.patches.append(self.allpatches[patch]) + else: + raise SelectionError('No patch item {}.'.format(patch)) + + +def parseopts(): + def flatten_commas(items): + """Turn a bunch of comma-separated lists into one flat list.""" + out = [] + for i in items: + for j in i.split(','): + try: + out.append(int(j)) + except ValueError: + print('Invalid value: {}'.format(j)) + sys.exit(1) + return out + + parser = argparse.ArgumentParser( + description='A tool to download sources and patches from spectiles.', + usage='%(prog)s [options] ', + epilog="Files:\n/etc/rpmdevrools/curlrc\n optional curl(1) configuration", + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False) + parser.add_argument('spec', help='The specfile to be parsed') + + mode = parser.add_argument_group('Operating mode') + mode1 = mode.add_mutually_exclusive_group() + mode1.add_argument('-l', '--lf', '--list-files', action='store_true', dest='listfiles', + help='lists the expanded sources/patches (default)') + mode1.add_argument('-g', '--gf', '--get-files', action='store_true', dest='getfiles', + help='gets the sources/patches that are listed with a URL') + mode.add_argument('-h', '--help', action='help', + help="display this help screen") + + select = parser.add_argument_group('Files on which to operate') + select.add_argument('-A', '--all', action='store_true', default=True, + help='all files, sources and patches (default)') + select.add_argument('-S', '--sources', action='store_true', dest='allsources', + help='all sources') + select.add_argument('-P', '--patches', action='store_true', dest='allpatches', + help='all patches') + select.add_argument('-s', '--source', action='append', dest='sourcelist', + help='specified sources', metavar='x[,y[,...]]') + select.add_argument('-p', '--patch', action='append', dest='patchlist', + help='specified patches', metavar='a[,b[,...]]') + + misc = parser.add_argument_group('Miscellaneous') + misc.add_argument('-d', '--define', action='append', dest='defines', + metavar="'macro value'", + help="defines RPM macro 'macro' to be 'value'") + + misc1 = misc.add_mutually_exclusive_group() + misc1.add_argument('-C', '--directory', action='store', dest='downloaddir', + metavar='dir', + help="download into specified directory (default '.')") + misc1.add_argument('-R', '--sourcedir', action='store_true', dest='downloadtosourcedir', + help="download into RPM's %%{_sourcedir}") + + misc.add_argument('-n', '--dryrun', '--dry-run', action='store_true', dest='dryrun', + help="don't download anything; just show what would be done") + misc.add_argument('-f', '--force', action='store_true', + help="try to unlink and download if target files exist") + + misc.add_argument('-D', '--debug', action='store_true', + help="output debug info, don't clean up when done") + + opts = parser.parse_args() + + # Can argparse do this for me? + if opts.allsources or opts.allpatches: + opts.all = False + if opts.sourcelist: + opts.all = opts.allsources = False + else: + opts.sourcelist = [] + + if opts.patchlist: + opts.all = opts.allpatches = False + else: + opts.patchlist = [] + + opts.sourcelist = flatten_commas(opts.sourcelist) + opts.patchlist = flatten_commas(opts.patchlist) + + if opts.debug: + def _dbprint(*dbargs): + print(*dbargs) + else: + _dbprint = lambda *x: None + global dbprint + dbprint = _dbprint + + return opts + + +def check_rpmspec(): + try: + run(['rpmspec', '--version']) + except CalledProcessError: + print("rpmspec does not appear to be installed.") + sys.exit(1) + + +def expand_sourcedir_macro(spec): + cmdline = ['rpm'] + for arg in 'epoch', 'name', 'release', 'version': + cmdline.extend(['--define', '{} {}'.format(arg, getattr(spec, arg))]) + cmdline.extend(['--eval', '%_sourcedir']) + + try: + proc = run(cmdline) + except ProcError as e: + print('Error: rpm call failed!') + print(e) + sys.exit(1) + return proc.stdout.strip() + + +def get_download_location(spec, opts): + dir = '.' + if opts.downloaddir: + dir = opts.downloaddir + if not opts.downloadtosourcedir: + return dir + return expand_sourcedir_macro(spec) + + +def is_downloadable(url): + """Check that string is a valid URL of a protocol which CURL can handle.""" + return False + + +def download_files(spec, opts, selected): + """ + Fetch the sources. + + Here's the relevant perl code: + if (retrievable ($url)) { + my $path = File::Spec->catfile($where, $url =~ m|([^/]+)$|); + print "Getting $url to $path\n"; + if (-e $path) { + if ($force) { + if (! unlink $path) { + warn("Could not unlink $path, skipping download: $!\n"); + return 1; + } + } else { + warn("$path already exists, skipping download\n"); + return 0; + } + } + # Note: -k/--insecure is intentionally not here; add it to + # $CURLRC if you want it. + my @cmd = (qw (curl --fail --remote-time --location + --output), $path, + '--user-agent', "spectool/$VERSION"); + push(@cmd, '--config', $CURLRC) if (-e $CURLRC); + push(@cmd, $url); + print "--> @cmd\n" if ($verbose > 1); + if (! $dryrun) { + system @cmd; + return $? == -1 ? 127 : $? >> 8; + } else { + print "dry run: @cmd\n"; + } + } else { + warn "Couldn't fetch $url: missing/unsupported URL\n" if ($verbose); + } + return 0; + """ + dir = get_download_location(spec, opts) + # urls = [] + + # Iterate over sources + + print(dir) + + +def listfiles(spec, opts, selected): + if opts.allsources or opts.all: + for source, num in sorted(zip(spec.sources, spec.sourcenums), key=operator.itemgetter(1)): + print("Source{}: {}".format(num, source)) + + if opts.allpatches or opts.all: + for patch, num in sorted(zip(spec.patches, spec.patchnums), key=operator.itemgetter(1)): + print("Patch{}: {}".format(num, patch)) + + for source in opts.sourcelist: + if source in spec.sourcenums: + print("Source{}: {}".format(source, spec.sources[source])) + else: + print('No source item {}.'.format(source)) + + for patch in opts.patchlist: + if patch in spec.patchnums: + print("Patch{}: {}".format(patch, spec.patches[patch])) + else: + print('No patch item {}.'.format(patch)) + + +def show_parsed_data(spec, opts): + print("Parsed these tags:") + print("-> Name: {}".format(spec.name)) + print("-> Epoch: {}".format(spec.version)) + print("-> Version: {}".format(spec.version)) + print("-> Release: {}".format(spec.release)) + print("-> Sources:\n -> {}".format('\n -> '.join(spec.sources))) + print("-> Patches:\n -> {}".format('\n -> '.join(spec.patches))) + print() + + +def main(): + opts = parseopts() + check_rpmspec() + spec = Spec(opts.spec) + + if opts.debug: + show_parsed_data(spec, opts) + + selected = Selections(spec, opts) + if not opts.getfiles or opts.listfiles: + listfiles(spec, opts, selected) + + if opts.getfiles: + download_files(spec, opts, selected) + + +if __name__ == '__main__': + main()