#!/usr/bin/python # Copyright 2011 Lars Wirzenius # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import cliapp import ConfigParser import debian.changelog import debian.deb822 import glob import logging import os import re import shutil import subprocess import tempfile __version__ = '0.0' class VcsOps(object): '''Abstract base class for VCS dependent operations.''' def __init__(self, app): self.app = app def one_of_ours(self): '''Is this project using our VCS?''' raise NotImplemented() def uncommitted_changes(self): '''Does the project have uncommitted changes?''' raise NotImplemented() def export_tarball(self, filename): '''Export the sources into an uncompressed tarball.''' raise NotImplemented() class BzrOps(VcsOps): def one_of_ours(self): return os.path.isdir('.bzr') def uncommitted_changes(self): out = self.app.runcmd(['bzr', 'status', '--short']) return [line.split()[1] for line in out.splitlines()] def export_tarball(self, filename): prefix, ext = os.path.splitext(filename) root = os.path.basename(prefix) self.app.runcmd(['bzr', 'export', '--format=tar', '--root=%s' % root, filename]) class GitOps(VcsOps): def one_of_ours(self): return os.path.isdir('.git') def uncommitted_changes(self): out = self.app.runcmd(['git', 'status', '--short']) return [line.split()[1] for line in out.splitlines()] def export_tarball(self, filename): prefix, ext = os.path.splitext(filename) root = os.path.basename(prefix) self.app.runcmd(['git', 'archive', '--prefix=%s/' % root, '--output=%s' % filename, 'HEAD']) class Unperish(cliapp.Application): def add_settings(self): self.settings.boolean(['verbose', 'v'], 'print commands that are executed') self.settings.boolean(['no-act', 'dry-run', 'n'], 'don\'t run commands') self.settings.string(['build-area'], 'where should results go? (%default)', default='../build-area') self.settings.string_list(['basetgz'], 'list of pbuilder basetgz tarballs to use, ' 'optionally prefix with upload target and ' 'architechture, separated by colons ' '(%default)', default=['/var/cache/pbuilder/base.tgz']) self.settings.string(['dsc'], 'Debian source package (for dget command)', metavar='URL') self.settings.string(['debian-version'], 'Debian version number (typically detected ' 'from debian/changelog)') self.settings.string(['debian-source'], 'Debian source package name (typically detected ' 'from debian/control)') self.settings.string(['web-directory'], 'put files to go on the web in DIR', metavar='DIR') self.settings.string_list(['doc'], 'add FILE to docs to publish with ' 'publish-docs (default: %default)', metavar='FILE', default=['README', 'NEWS']) self.settings.string_list(['rsync-glob'], 'publish files matching GLOB with rsync', metavar='GLOB') self.settings.string_list(['rsync-to'], 'publish files with rsync to LOCATION', metavar='LOCATION') self.settings.choice(['full-source'], ['auto', 'yes', 'no'], 'include full source in upload?') self.settings.boolean(['binary-arch'], 'build arch-specific packages only, ' 'not arch:all') self.settings.string(['upstream-name'], 'upstream name for project') self.settings.string(['upstream-version'], 'upstream version for project') self.settings.boolean(['use-uncommitted'], 'use uncommited changes') self.settings.config_files += ['project.meta'] def process_args(self, args): self.deduce_unset_settings() self.deduce_vcs() self.find_project_specific_settings() self.create_build_area() self.already = set() for arg in args: self.run_subcommand(arg) def run_subcommand(self, subcommand, force=False): if force or subcommand not in self.already: self.already.add(subcommand) if subcommand in self.subcommands: method = self.subcommands[subcommand] if self.settings['verbose']: self.output.write('command: %s\n' % subcommand) if not self.settings['no-act']: method([]) else: raise cliapp.AppException('unknown command %s' % subcommand) def deduce_unset_settings(self): def deduce_upstream_name(): if os.path.exists('setup.py'): return self.runcmd(['python', 'setup.py', '--name']).strip() return '' def deduce_upstream_version(): if os.path.exists('setup.py'): return self.runcmd(['python', 'setup.py', '--version']).strip() return '' table = { 'upstream-name': deduce_upstream_name, 'upstream-version': deduce_upstream_version, } for name in table: if not self.settings[name]: self.settings[name] = table[name]() def deduce_vcs(self): classes = [ BzrOps, GitOps, ] for klass in classes: obj = klass(self) if obj.one_of_ours(): self.vcs = obj return raise cliapp.AppException('Do not know this VCS, if any.') def find_project_specific_settings(self): cp = self.settings.as_cp() section = 'project %s' % self.upstream_name if cp.has_section(section): for option in cp.options(section): if cp.has_option('config', option): value = cp.get(section, option) self.settings.set_from_raw_string(option, value) def create_build_area(self): if not os.path.exists(self.settings['build-area']): os.mkdir(self.settings['build-area']) @property def upstream_name(self): return self.settings['upstream-name'] @property def upstream_version(self): return self.settings['upstream-version'] @property def upstream_tarball(self): return '%s-%s.tar.gz' % (self.upstream_name, self.upstream_version) @property def debian_control(self): with open(self.join(self.dirname, 'debian', 'control')) as f: return debian.deb822.Deb822(f) @property def debian_changelog(self): with open(self.join(self.dirname, 'debian', 'changelog')) as f: return debian.changelog.Changelog(f) @property def debian_source_package(self): if self.settings['debian-source']: return self.settings['debian-source'] return self.debian_control['Source'] @property def debian_version(self): if self.settings['debian-version']: return self.settings['debian-version'] return str(self.debian_changelog.get_version()) @property def debian_tarball(self): is_native = '-' not in self.debian_version if is_native: pattern = '%s_%s.tar.gz' else: pattern = '%s_%s.orig.tar.gz' return pattern % (self.debian_source_package, self.upstream_version) @property def dirname(self): return '%s-%s' % (self.upstream_name, self.upstream_version) @property def dsc(self): return '%s_%s.dsc' % (self.debian_source_package, self.debian_version) @property def arch(self): return self.runcmd(['dpkg', '--print-architecture']).strip() def changes(self, arch): return '%s_%s_%s.changes' % (self.debian_source_package, self.debian_version, arch) def already_exists(self, filename): '''Does a file already exist?''' if os.path.exists(filename): logging.debug('Already exists: %s' % filename) return True else: logging.debug('Does not already exist: %s' % filename) return False def cmd_committed(self, args): '''Check that all changes have been committed.''' if not self.settings['use-uncommitted']: changes = self.vcs.uncommitted_changes() if changes: raise cliapp.AppException('Uncommitted changes:\n%s' % '\n'.join(changes)) def cmd_dget(self, args): '''Retrieve a Debian source package (.dsc and other files). Put the files in the build area. ''' if not self.settings['dsc']: raise cliapp.AppException('Need --dsc option for dget') basename = os.path.basename(self.settings['dsc']) if not self.already_exists(self.join(basename)): self.runcmd(['dget', '--download-only', self.settings['dsc']], cwd=self.settings['build-area']) def cmd_export_upstream_tarball(self, args): '''Export upstream sources as a compressed tarball. The tarball won't include debian/ directory, if any. ''' self.run_subcommand('committed') tarball = self.join(self.upstream_tarball) if not self.already_exists(tarball): tempdir = tempfile.mkdtemp() dirname = '%s-%s' % (self.upstream_name, self.upstream_version) temp_tar = os.path.join(tempdir, dirname + '.tar') self.vcs.export_tarball(temp_tar) self.runcmd(['tar', '-xf', dirname + '.tar'], cwd=tempdir) if self.settings['use-uncommitted']: for name in self.vcs.uncommitted_changes(): shutil.copy2(name, self.join(tempdir, dirname, name)) debian_dir = os.path.join(tempdir, dirname, 'debian') if os.path.exists(debian_dir): shutil.rmtree(debian_dir) self.runcmd(['tar', '-caf', tarball, dirname], cwd=tempdir) shutil.rmtree(tempdir) def cmd_export(self, args): '''Export unpacked source directory to build area. This will include the debian/ directory, too. ''' self.run_subcommand('committed') dirname = self.join(self.dirname) if not self.already_exists(dirname): temp_tar = os.path.join(dirname + '.tar') self.vcs.export_tarball(temp_tar) self.runcmd(['tar', '-xf', dirname + '.tar'], cwd=self.settings['build-area']) os.remove(temp_tar) if self.settings['use-uncommitted']: for name in self.vcs.uncommitted_changes(): shutil.copy2(name, self.join(dirname, name)) if not self.already_exists(self.join(self.dirname)): self.runcmd(['tar', '-xf', self.join(self.upstream_tarball)], cwd=self.settings['build-area']) def cmd_debian_tarball(self, args): '''Generate Debian tarball (.orig.tar.gz) in build area.''' self.run_subcommand('export-upstream-tarball') origtar = self.join(self.debian_tarball) if not self.already_exists(origtar): os.link(self.join(self.upstream_tarball), origtar) def cmd_dsc(self, args): '''Create Debian source package (.dsc) in build area.''' self.run_subcommand('export') self.run_subcommand('debian-tarball') if not self.already_exists(self.join(self.dsc)): self.runcmd(['dpkg-source', '-b', self.dirname], cwd=self.settings['build-area']) def cmd_deb(self, args): '''Build Debian binary packages (.deb) in build area.''' self.run_subcommand('dsc') self.run_subcommand('export') targets = {} for spec in self.settings['basetgz']: target, arch, path = self.parse_basetgz(spec) targets[target] = targets.get(target, []) + [(arch, path)] deb_changelog = self.join(self.dirname, 'debian', 'changelog') with open(deb_changelog) as f: changelog_text = f.read() for target in sorted(targets.keys()): if target: with open(deb_changelog, 'w') as f: f.write(changelog_text) self.add_debian_changelog_entry(target) for arch, path in targets[target]: self.run_subcommand('dsc', force=True) changes = self.join(self.changes(arch)) if not self.already_exists(changes): prefix, ext = os.path.splitext(changes) logfile = prefix + '.pbuilder.log' argv = ['sudo', 'pbuilder', '--build', '--basetgz', path, '--buildresult', self.settings['build-area'], '--allow-untrusted', '--logfile', logfile] if self.include_source(): argv.extend(['--debbuildopts', '-sa']) if self.settings['binary-arch']: argv.append('--binary-arch') argv.append(self.join(self.dsc)) self.runcmd(argv, cwd=self.settings['build-area']) def include_source(self): '''Should the upload include full source?''' if self.settings['full-source'] == 'yes': return True if self.settings['full-source'] == 'no': return False pat = r'-1$|[a-z]1$|^[^-]*$' return re.search(pat, self.debian_version) is not None def parse_basetgz(self, spec): parts = spec.split(':', 3) n = len(parts) if n == 1 or '/' in parts[0]: target = None arch = self.arch path = parts[0] elif n == 2: target = parts[0] arch = self.arch path = parts[1] elif n == 3 and '/' in parts[1]: target = parts[0] arch = self.arch path = ':'.join(parts[1:]) else: target = parts[0] arch = parts[1] path = parts[2] return target, arch, path def cmd_lintian(self, args): '''Run lintian on .changes/.deb/.dsc files.''' def find_them(suffixes): return [os.path.join(self.settings['build-area'], x) for x in os.listdir(self.settings['build-area']) if os.path.splitext(x)[1] in suffixes] files = find_them(['.changes']) if not files: files = find_them(['.deb', '.dsc']) out = self.runcmd(['lintian', '-i'] + files, ignore_fail=True, cwd=self.settings['build-area']) self.output.write(out) def cmd_publish_docs(self, args): '''Publish docs related to this project. By default, the README and NEWS files and any manual pages are published. README and NEWS can be replaced with the --doc option. Any documents get a .mdwn suffix added: it is assumed that they're marked up using Markdown and published in an ikiwiki instance. Manual pages are formatted into plain text and get a .txt suffix added when copying. ''' def publish(source, target_base): target = os.path.join(self.settings['web-directory'], target_base) if self.settings['verbose']: print 'Copying %s to %s' % (source, target) shutil.copyfile(source, target) if not self.settings['web-directory']: raise cliapp.AppException('Need --web-directory ' 'for publish-docs.') for doc in self.settings['doc']: src = self.join(self.dirname, doc) if os.path.exists(src): publish(src, doc + '.mdwn') env = dict(os.environ) env['LC_ALL'] = 'C' for manpage in glob.glob(self.join(self.dirname, '*.[1-8]')): fmt = self.runcmd(['man', '-l', manpage], env=env) text = self.runcmd(['col', '-b'], feed_stdin=fmt) fd, name = tempfile.mkstemp() os.write(fd, text) os.close(fd) publish(name, os.path.basename(manpage) + '.txt') os.remove(name) def cmd_rsync_publish(self, args): '''Publish files via rsync.''' filenames = [] for pattern in self.settings['rsync-glob']: filenames += glob.glob(pattern) logging.debug('filenames: %s' % filenames) self.runcmd(['rsync', '-av', '--delete-after'] + filenames + self.settings['rsync-to']) def cmd_clean(self, args): '''Clean up the build-area (remove everything except the dir).''' area = self.settings['build-area'] if os.path.isdir(area): for x in os.listdir(area): pathname = os.path.join(area, x) if os.path.isdir(pathname): shutil.rmtree(pathname) else: os.remove(pathname) def add_debian_changelog_entry(self, target): msg = 'Build for %s.' % target self.runcmd(['dch', '--force-distribution', '--local', '~' + target, '--distribution', target, '--preserve', msg], cwd=self.join(self.dirname)) def join(self, *components): area = os.path.abspath(self.settings['build-area']) components = (area,) + components return os.path.join(*components) def runcmd(self, argv, *args, **kwargs): logging.debug('runcmd: argv: %s' % repr(argv)) logging.debug('runcmd: args: %s' % repr(args)) logging.debug('runcmd: kwargs: %s' % repr(kwargs)) if self.settings['verbose']: self.output.write('run: %s\n' % ' '.join(argv)) return cliapp.Application.runcmd(self, argv, *args, **kwargs) if __name__ == '__main__': Unperish(version=__version__).run()