Current File : //bin/debdiff-apply |
#!/usr/bin/python3
# Copyright (c) 2016-2017, Ximin Luo <infinity0@debian.org>
#
# 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.
#
# See file /usr/share/common-licenses/GPL-3 for more details.
#
"""
Apply a debdiff to a Debian source package.
It handles d/changelog hunks specially, to avoid conflicts.
Depends on dpkg-dev, devscripts, python3-unidiff, quilt.
"""
import argparse
import email.utils
import hashlib
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unidiff
from debian.changelog import Changelog, ChangeBlock
# this can be any valid value, it doesn't appear in the final output
DCH_DUMMY_TAIL = "\n -- debdiff-apply dummy tool <infinity0@debian.org> " \
"Thu, 01 Jan 1970 00:00:00 +0000\n\n"
CHBLOCK_DUMMY_PACKAGE = "debdiff-apply PLACEHOLDER"
TRY_ENCODINGS = ["utf-8", "latin-1"]
DISTRIBUTION_DEFAULT = "experimental"
def workaround_dpkg_865430(dscfile, origdir, stdout):
filename = subprocess.check_output(
["dcmd", "--tar", "echo", dscfile]).rstrip()
if not os.path.exists(os.path.join(origdir.encode("utf-8"), os.path.basename(filename))):
subprocess.check_call(
["dcmd", "--tar", "cp", dscfile, origdir], stdout=stdout)
def is_dch(path):
dirname = os.path.dirname(path)
return (os.path.basename(path) == 'changelog'
and os.path.basename(dirname) == 'debian'
and os.path.dirname(os.path.dirname(dirname)) == '')
def hunk_lines_to_str(hunk_lines):
return "".join(map(lambda x: str(x)[1:], hunk_lines))
def read_dch_patch(dch_patch):
if len(dch_patch) > 1:
raise ValueError("don't know how to deal with debian/changelog patch "
"that has more than one hunk")
hunk = dch_patch[0]
source_str = hunk_lines_to_str(hunk.source_lines()) + DCH_DUMMY_TAIL
target_str = hunk_lines_to_str(hunk.target_lines())
# here we assume the debdiff has enough context to see the previous version
# this should be true all the time in practice
source_version = str(Changelog(source_str, 1)[0].version)
target = Changelog(target_str, 1)[0]
return source_version, target
def apply_dch_patch(source_file, current, old_version, target, dry_run):
target_version = str(target.version)
if not old_version or not target_version.startswith(old_version):
logging.warning("don't know how to rebase version-change (%s => %s) onto %s",
old_version, target_version, old_version)
newlog = subprocess.getoutput("EDITOR=cat dch -n 2>/dev/null").rstrip()
version = str(Changelog(newlog, 1)[0].version)
logging.warning("using version %s based on `dch -n`; feel free to make me smarter",
version)
else:
version_suffix = target_version[len(old_version):]
version = str(current[0].version) + version_suffix
logging.info("using version %s based on suffix %s",
version, version_suffix)
if dry_run:
return version
current._blocks.insert(0, target) # pylint: disable=protected-access
current.set_version(version)
shutil.copy(source_file, source_file + ".new")
try:
# disable unspecified-encoding, as in Mattia's opinion this should
# likely be rewritten to use pure binary instead of encode/decode.
with open(source_file + ".new", "w") as fp: # pylint: disable=unspecified-encoding
current.write_to_open_file(fp)
os.rename(source_file + ".new", source_file)
except Exception:
logging.warning("failed to patch %s", source_file)
logging.warning("half-applied changes in %s", source_file + ".new")
logging.warning("current working directory is %s", os.getcwd())
raise
return version
def call_patch(patch_str, *args, check=True, **kwargs):
return subprocess.run(
["patch", "-p1"] + list(args),
input=patch_str,
universal_newlines=True,
check=check,
**kwargs)
def check_patch(patch_str, *args, **kwargs):
return call_patch(patch_str,
"--dry-run", "-f", "--silent",
*args,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**kwargs).returncode == 0
def debdiff_apply(patch, patch_name, args):
# don't change anything if...
dry_run = args.target_version or args.source_version
changelog = list(filter(lambda x: is_dch(x.path), patch))
if not changelog:
logging.info("no debian/changelog in patch: %s", args.patch_file)
old_version = None
target = ChangeBlock(
package=CHBLOCK_DUMMY_PACKAGE,
author="%s <%s>" % (os.getenv("DEBFULLNAME"),
os.getenv("DEBEMAIL")),
date=email.utils.formatdate(time.time(), localtime=True),
version=None,
distributions=args.distribution,
urgency="low",
changes=["", " * Rebase patch %s." % patch_name, ""],
)
target.add_trailing_line("")
elif len(changelog) > 1:
raise ValueError("more than one debian/changelog patch???")
else:
patch.remove(changelog[0])
old_version, target = read_dch_patch(changelog[0])
if args.source_version:
if old_version:
print(old_version)
return False
# read this here so --source-version can work even without a d/changelog
with open(args.changelog, encoding="utf8") as fp:
current = Changelog(fp.read())
if target.package == CHBLOCK_DUMMY_PACKAGE:
target.package = current[0].package
if not dry_run:
patch_str = str(patch)
if check_patch(patch_str, "-N"):
call_patch(patch_str)
logging.info("patch %s applies!", patch_name)
elif check_patch(patch_str, "-R"):
logging.warning("patch %s already applied", patch_name)
return False
else:
call_patch(patch_str, "--dry-run", "-f")
raise ValueError("patch %s doesn't apply!" % (patch_name))
# only apply d/changelog patch if the rest of the patch applied
new_version = apply_dch_patch(
args.changelog, current, old_version, target, dry_run)
if args.target_version:
print(new_version)
return False
if args.repl:
import code # pylint: disable=import-outside-toplevel
code.interact(local=locals())
return True
def parse_args(args):
parser = argparse.ArgumentParser(
description='Apply a debdiff to a Debian source package')
parser.add_argument(
'-v', '--verbose', action="store_true",
help='Output more information',
)
parser.add_argument(
'-c', '--changelog', default='debian/changelog',
help='Path to debian/changelog; default: %(default)s',
)
parser.add_argument(
'-D', '--distribution', default='experimental',
help='Distribution to use, if the patch doesn\'t already '
'contain a changelog; default: %(default)s',
)
parser.add_argument(
'--repl', action="store_true",
help="Run the python REPL after processing.",
)
parser.add_argument(
'--source-version', action="store_true",
help='Don\'t apply the patch; instead print out the version of the '
'package that it is supposed to be applied to, or nothing if '
'the patch does not specify a source version.',
)
parser.add_argument(
'--target-version', action="store_true",
help="Don't apply the patch; instead print out the new version of the "
"package debdiff-apply(1) would generate, when the patch is applied to the "
"the given target package, as specified by the other arguments.",
)
parser.add_argument(
'orig_dsc_or_dir', nargs='?', default=".",
help="Target to apply the patch to. This can either be an unpacked "
"source tree, or a .dsc file. In the former case, the directory is "
"modified in-place; in the latter case, a second .dsc is created. "
"Default: %(default)s",
)
parser.add_argument(
'patch_file', nargs='?', default="/dev/stdin",
help="Patch file to apply, in the format output by debdiff(1). "
"Default: %(default)s",
)
group1 = parser.add_argument_group('Options for .dsc patch targets')
group1.add_argument(
'--no-clean', action="store_true",
help="Don't clean temporary directories after a failure, so you can "
"examine what failed.",
)
group1.add_argument(
'--quilt-refresh', action="store_true",
help="If the building of the new source package fails, try to refresh "
"patches using quilt(1) then try building it again.",
)
group1.add_argument(
'-d', '--directory', default=None,
help="Extract the .dsc into this directory, which won't be cleaned up "
"after debdiff-apply(1) exits. If not given, then it will be extracted to a "
"temporary directory.",
)
return parser.parse_args(args)
def main(args):
# Split this function! pylint: disable=too-many-branches,too-many-locals,too-many-statements
args = parse_args(args)
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
with open(args.patch_file, 'rb') as fp:
data = fp.read()
for enc in TRY_ENCODINGS:
try:
patch = unidiff.PatchSet(
data.splitlines(keepends=True), encoding=enc)
break
except Exception: # pylint: disable=broad-except
if enc == TRY_ENCODINGS[-1]:
raise
continue
patch_name = '%s:%s' % (
os.path.basename(args.patch_file),
hashlib.sha256(data).hexdigest()[:20 if args.patch_file == '/dev/stdin' else 8])
quiet = args.source_version or args.target_version
dry_run = args.source_version or args.target_version
# user can redirect stderr themselves
stdout = subprocess.DEVNULL if quiet else None
# change directory before applying patches
if os.path.isdir(args.orig_dsc_or_dir):
os.chdir(args.orig_dsc_or_dir)
debdiff_apply(patch, patch_name, args)
elif os.path.isfile(args.orig_dsc_or_dir):
dscfile = args.orig_dsc_or_dir
parts = os.path.splitext(os.path.basename(dscfile))
if parts[1] != ".dsc":
raise ValueError("unrecognised patch target: %s" % dscfile)
extractdir = args.directory if args.directory else tempfile.mkdtemp()
if not os.path.isdir(extractdir):
os.makedirs(extractdir)
try:
# dpkg-source doesn't like existing dirs
builddir = os.path.join(extractdir, parts[0])
subprocess.check_call(["dpkg-source", "-x", "--skip-patches", dscfile, builddir],
stdout=stdout)
origdir = os.getcwd()
workaround_dpkg_865430(dscfile, origdir, stdout)
os.chdir(builddir)
did_patch = debdiff_apply(patch, patch_name, args)
if dry_run or not did_patch:
return
os.chdir(origdir)
try:
subprocess.check_call(["dpkg-source", "-b", builddir])
except subprocess.CalledProcessError:
if args.quilt_refresh:
subprocess.check_call(["sh", "-c", """
set -ex
export QUILT_PATCHES=debian/patches
while quilt push; do quilt refresh; done
"""], cwd=builddir)
subprocess.check_call(["dpkg-source", "-b", builddir])
else:
raise
finally:
cleandir = builddir if args.directory else extractdir
if args.no_clean:
logging.warning(
"you should clean up temp files in %s", cleandir)
else:
shutil.rmtree(cleandir)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))