Path: blob/main/Tools/scripts/patchtool.py
16123 views
#!/usr/bin/env python1# ex:ts=42#-*- mode: Fundamental; tab-width: 4; -*-3#4# patchtool.py - a tool to automate common operation with patchfiles in the5# FreeBSD Ports Collection.6#7# ----------------------------------------------------------------------------8# "THE BEER-WARE LICENSE" (Revision 42, (c) Poul-Henning Kamp):9# Maxim Sobolev <[email protected]> wrote this file. As long as you retain10# this notice you can do whatever you want with this stuff. If we meet some11# day, and you think this stuff is worth it, you can buy me a beer in return.12#13# Maxim Sobolev14# ----------------------------------------------------------------------------15#16# MAINTAINER= [email protected] <- any unapproved commits to this file are17# highly discouraged!!!18#1920import os, os.path, subprocess, sys, getopt, glob, errno, types2122# python3 lacks raw_input23compat_raw_input = input24if sys.version_info < (3,):25compat_raw_input = raw_input2627# Some global variables used as constants28#True = 129#False = 03031def isStr(obj):32try:33return isinstance(obj, basestring)34except NameError:35return isinstance(obj, str)3637# Tweakable global variables. User is able to override any of these by setting38# appropriate environment variable prefixed by `PT_', eg:39# $ export PT_CVS_ID="FooOS"40# $ export PT_DIFF_CMD="/usr/local/bin/mydiff"41# will force script to use "FooOS" as a CVS_ID instead of "FreeBSD" and42# "/usr/local/bin/mydiff" as a command to generate diffs.43class Vars:44CVS_ID = 'FreeBSD'4546DIFF_ARGS = '-du'47DIFF_SUFX = '.orig'48PATCH_PREFIX = 'patch-'49PATCH_IGN_SUFX = ('.orig', '.rej')50RCSDIFF_SUFX = ',v'5152CD_CMD = 'cd'53DIFF_CMD = '/usr/bin/diff'54MAKE_CMD = '/usr/bin/make'55PRINTF_CMD = '/usr/bin/printf'56RCSDIFF_CMD = '/usr/bin/rcsdiff'5758DEFAULT_MAKEFILE = 'Makefile'59DEV_NULL = '/dev/null'60ETC_MAKE_CONF = '/etc/make.conf'6162SLASH_REPL_SYMBOL = '_' # The symbol to replace '/' when auto-generating63# patchnames646566#67# Check if the supplied patch refers to a port's directory.68#69def isportdir(path, soft = False):70REQ_FILES = ('Makefile', 'pkg-descr', 'distinfo')71if not os.path.isdir(path) and soft != True:72raise IOError(errno.ENOENT, path)73# Not reached #7475try:76content = os.listdir(path)77except OSError:78return False7980for file in REQ_FILES:81if file not in content:82return False83return True848586#87# Traverse directory tree up from the path pointed by argument and return if88# root directory of a port is found.89#90def locateportdir(path, wrkdirprefix= '', strict = False):91# Flag to relax error checking in isportdir() function. It required when92# WRKDIRPREFIX is defined.93softisport = False9495path = os.path.abspath(path)9697if wrkdirprefix != '':98wrkdirprefix= os.path.abspath(wrkdirprefix)99commonprefix = os.path.commonprefix((path, wrkdirprefix))100if commonprefix != wrkdirprefix:101return ''102path = path[len(wrkdirprefix):]103softisport = True104105while path != '/':106if isportdir(path, softisport) == True:107return path108path = os.path.abspath(os.path.join(path, '..'))109110if strict == True:111raise LocatePDirError(path)112# Not reached #113else:114return ''115116117#118# Get value of a make(1) variable called varname. Optionally maintain a cache119# for resolved varname:makepath pairs to speed-up operation if the same variable120# from the exactly same file is requested repeatedly (invocation of make(1) is121# very expensive operation...)122#123def querymakevar(varname, path = 'Makefile', strict = False, cache = {}):124path = os.path.abspath(path)125126if (varname, path) in cache:127return cache[(varname, path)]128129origpath = path130if os.path.isdir(path):131path = os.path.join(path, Vars.DEFAULT_MAKEFILE)132if not os.path.isfile(path):133raise IOError(errno.ENOENT, path)134# Not reached #135136dir = os.path.dirname(path)137CMDLINE = '%s %s && %s -f %s -V %s' % (Vars.CD_CMD, dir, Vars.MAKE_CMD, \138path, varname)139devnull = open('/dev/null', 'a')140pipe = subprocess.Popen(CMDLINE, shell = True, stdin = subprocess.PIPE, \141stdout = subprocess.PIPE, stderr = devnull, close_fds = True)142retval = ''143for line in pipe.stdout.readlines():144retval = retval + line.decode().strip() + ' '145retval = retval[:-1]146if strict == True and retval.strip() == '':147raise MakeVarError(path, varname)148# Not reached #149150cache[(varname, origpath)] = retval151return retval152153154#155# Get a path of `path' relatively to wrksrc. For example:156# path: /foo/bar157# wrksrc: /foo/bar/baz/somefile.c158# getrelpath: baz/somefile.c159# Most of the code here is to handle cases when ../ operation is required to160# reach wrksrc from path, for example:161# path: /foo/bar162# wrksrc: /foo/baz/somefile.c163# getrelpath: ../baz/somefile.c164#165def getrelpath(path, wrksrc):166path = os.path.abspath(path)167wrksrc = os.path.abspath(wrksrc) + '/'168commonpart = os.path.commonprefix((path, wrksrc))169while commonpart[-1:] != '/':170commonpart = commonpart[:-1]171path = path[len(commonpart):]172wrksrc = wrksrc[len(commonpart):]173adjust = ''174while os.path.normpath(os.path.join(wrksrc, adjust)) != '.':175adjust = os.path.join(adjust, '..')176relpath = os.path.join(adjust, path)177return relpath178179180#181# Generate a diff between saved and current versions of the file pointed by the182# wrksrc+path. Apply heuristics to locate saved version of the file in question183# and if it fails assume that file is new, so /dev/null is to be used as184# original file. Optionally save generated patch into `outfile' instead of185# dumping it to stdout. Generated patches automatically being tagged with186# "FreeBSD" cvs id.187#188def gendiff(path, wrksrc, outfile = ''):189fullpath = os.path.join(wrksrc, path)190if not os.path.isfile(fullpath):191raise IOError(errno.ENOENT, fullpath)192# Not reached #193194cmdline = ''195if os.path.isfile(fullpath + Vars.DIFF_SUFX): # Normal diff196path_orig = path + Vars.DIFF_SUFX197cmdline = '%s %s %s %s' % (Vars.DIFF_CMD, Vars.DIFF_ARGS, path_orig, path)198elif os.path.isfile(fullpath + Vars.RCSDIFF_SUFX): # RCS diff199path_orig = path200cmdline = '%s %s %s' % (Vars.RCSDIFF_CMD, Vars.DIFF_ARGS, path)201else: # New file202path_orig = Vars.DEV_NULL203cmdline = '%s %s %s %s' % (Vars.DIFF_CMD, Vars.DIFF_ARGS, path_orig, path)204205savedir = os.getcwd()206os.chdir(wrksrc)207devnull = open('/dev/null', 'a')208pipe = subprocess.Popen(cmdline, shell = True, stdin = subprocess.PIPE, \209stdout = subprocess.PIPE, stderr = devnull, close_fds = True)210outbuf = [x.decode() for x in pipe.stdout.readlines()]211exitval = pipe.wait()212if exitval == 0: # No differences were found213retval = False214retmsg = 'no differences found between original and current ' \215'version of "%s"' % fullpath216elif exitval == 1: # Some differences were found217if (outfile != ''):218outbuf[0] = '--- %s\n' % path_orig219outbuf[1] = '+++ %s\n' % path220open(outfile, 'w').writelines(outbuf)221else:222sys.stdout.writelines(outbuf)223retval = True224retmsg = ''225else: # Error occurred226raise ECmdError('"%s"' % cmdline, \227'external command returned non-zero error code')228# Not reached #229230os.chdir(savedir)231return (retval, retmsg)232233234#235# Automatically generate a name for a patch based on its path relative to236# wrksrc. Use simple scheme to ensure 1-to-1 mapping between path and237# patchname - replace all '_' with '__' and all '/' with '_'.238#239def makepatchname(path, patchdir = ''):240SRS = Vars.SLASH_REPL_SYMBOL241retval = Vars.PATCH_PREFIX + \242path.replace(SRS, SRS + SRS).replace('/', SRS)243retval = os.path.join(patchdir, retval)244return retval245246247#248# Write a specified message to stderr.249#250def write_msg(message):251if isStr(message):252message = message,253sys.stderr.writelines(message)254255256#257# Print specified message to stdout and ask user [y/N]?. Optionally allow258# specify default answer, i.e. return value if user typed only <cr>259#260def query_yn(message, default = False):261while True:262if default == True:263yn = 'Y/n'264elif default == False:265yn = 'y/N'266else:267yn = 'Y/N'268269reply = compat_raw_input('%s [%s]: ' % (message, yn))270271if reply == 'y' or reply == 'Y':272return True273elif reply == 'n' or reply == 'N':274return False275elif reply == '' and default in (True, False):276return default277print('Wrong answer "%s", please try again' % reply)278return default279280281#282# Print optional message and usage information and exit with specified exit283# code.284#285def usage(code, msg = ''):286myname = os.path.basename(sys.argv[0])287write_msg((str(msg), """288Usage: %s [-afi] file ...289%s -u [-i] [patchfile|patchdir ...]290""" % (myname, myname)))291sys.exit(code)292293294#295# Simple custom exception296#297class MyError(Exception):298msg = 'error'299300def __init__(self, file, msg=''):301self.file = file302if msg != '':303self.msg = msg304305def __str__(self):306return '%s: %s' % (self.file, self.msg)307308309#310# Error parsing patchfile311#312class PatchError(MyError):313msg = 'corrupt patchfile, or not patchfile at all'314315316#317# Error executing external command318#319class ECmdError(MyError):320pass321322323#324# Error getting value of makefile variable325#326class MakeVarError(MyError):327def __init__(self, file, makevar, msg=''):328self.file = file329if msg != '':330self.msg = msg331else:332self.msg = 'can\'t get %s value' % makevar333334335#336# Error locating portdir337#338class LocatePDirError(MyError):339msg = 'can\'t locate portdir'340341342class Patch:343fullpath = ''344minus3file = ''345plus3file = ''346wrksrc = ''347patchmtime = 0348targetmtime = 0349350def __init__(self, path, wrksrc):351MINUS3_DELIM = '--- '352PLUS3_DELIM = '+++ '353354path = os.path.abspath(path)355if not os.path.isfile(path):356raise IOError(errno.ENOENT, path)357# Not reached #358359self.fullpath = path360filedes = open(path)361362for line in filedes.readlines():363if self.minus3file == '':364if line[:len(MINUS3_DELIM)] == MINUS3_DELIM:365lineparts = line.split()366try:367self.minus3file = lineparts[1]368except IndexError:369raise PatchError(path)370# Not reached #371continue372elif line[:len(PLUS3_DELIM)] == PLUS3_DELIM:373lineparts = line.split()374try:375self.plus3file = lineparts[1]376except IndexError:377raise PatchError(path)378# Not reached #379break380381filedes.close()382383if self.minus3file == '' or self.plus3file == '':384raise PatchError(path)385# Not reached #386387self.wrksrc = os.path.abspath(wrksrc)388self.patchmtime = os.path.getmtime(self.fullpath)389plus3file = os.path.join(self.wrksrc, self.plus3file)390if os.path.isfile(plus3file):391self.targetmtime = os.path.getmtime(plus3file)392else:393self.targetmtime = 0394395def update(self, patch_cookiemtime = 0, ignoremtime = False):396targetfile = os.path.join(self.wrksrc, self.plus3file)397if not os.path.isfile(targetfile):398raise IOError(errno.ENOENT, targetfile)399# Not reached #400401patchdir = os.path.dirname(self.fullpath)402if not os.path.isdir(patchdir):403os.mkdir(patchdir)404405if ignoremtime == True or self.patchmtime == 0 or \406self.targetmtime == 0 or \407(self.patchmtime < self.targetmtime and \408patch_cookiemtime < self.targetmtime):409retval = gendiff(self.plus3file, self.wrksrc, self.fullpath)410if retval[0] == True:411self.patchmtime = os.path.getmtime(self.fullpath)412else:413retval = (False, 'patch is already up to date')414return retval415416417class NewPatch(Patch):418def __init__(self, patchdir, wrksrc, relpath):419self.fullpath = makepatchname(relpath, os.path.abspath(patchdir))420self.wrksrc = os.path.abspath(wrksrc)421self.plus3file = relpath422self.minus3file = relpath423self.patchmtime = 0424plus3file = os.path.join(self.wrksrc, self.plus3file)425if os.path.isfile(plus3file):426self.targetmtime = os.path.getmtime(plus3file)427else:428self.targetmtime = 0429430431class PatchesCollection:432patches = {}433434def __init__(self):435self.patches = {}436pass437438def adddir(self, patchdir, wrksrc):439if not os.path.isdir(patchdir):440raise IOError(errno.ENOENT, patchdir)441# Not reached #442443for filename in glob.glob(os.path.join(patchdir, Vars.PATCH_PREFIX + '*')):444for sufx in Vars.PATCH_IGN_SUFX:445if filename[-len(sufx):] == sufx:446write_msg('WARNING: patchfile "%s" ignored\n' % filename)447break448else:449self.addpatchfile(filename, wrksrc)450451def addpatchfile(self, path, wrksrc):452path = os.path.abspath(path)453if path not in self.patches:454self.addpatchobj(Patch(path, wrksrc))455456def addpatchobj(self, patchobj):457self.patches[patchobj.fullpath] = patchobj458459def lookupbyname(self, path):460path = os.path.abspath(path)461if path in self.patches:462return self.patches[path]463return None464465def lookupbytarget(self, wrksrc, relpath):466wrksrc = os.path.abspath(wrksrc)467for patch in self.patches.values():468if wrksrc == patch.wrksrc and relpath == patch.plus3file:469return patch470return None471472def getpatchobjs(self):473return self.patches.values()474475476#477# Resolve all symbolic links in the given path to a file478#479def truepath(path):480if not os.path.isfile(path):481raise IOError(errno.ENOENT, path)482483result = ''484while len(path) > 0:485path, lastcomp = os.path.split(path)486if len(lastcomp) == 0:487lastcomp = path488path = ''489result = os.path.join(lastcomp, result)490if len(path) == 0:491break492if os.path.islink(path):493linkto = os.path.normpath(os.readlink(path))494if linkto[0] != '/':495path = os.path.join(path, linkto)496else:497path = linkto498return result[:-1]499500501def main():502try:503opts, args = getopt.getopt(sys.argv[1:], 'afui')504except getopt.GetoptError as msg:505usage(2, msg)506507automatic = False508force = False509mode = generate510ignoremtime = False511512for o, a in opts:513if o == '-a':514automatic = True515elif o == '-f':516force = True517elif o == '-u':518mode = update519elif o == '-i':520ignoremtime = True521else:522usage(2)523524# Allow user to override internal constants525for varname in dir(Vars):526if varname[:2] == '__' and varname[-2:] == '__':527continue528try:529value = os.environ['PT_' + varname]530setattr(Vars, varname, value)531except KeyError:532pass533534mode(args, automatic, force, ignoremtime)535536sys.exit(0)537538539#540# Display a diff or generate patchfile for the files pointed out by args.541#542def generate(args, automatic, force, ignoremtime):543if len(args) == 0:544usage(2, "ERROR: no input files specified")545546patches = PatchesCollection()547548for filepath in args:549for suf in Vars.RCSDIFF_SUFX, Vars.DIFF_SUFX:550if filepath.endswith(suf):551filepath = filepath[:-len(suf)]552break553if not os.path.isfile(filepath):554raise IOError(errno.ENOENT, filepath)555# Not reached #556557filepath = truepath(filepath)558559wrkdirprefix = querymakevar('WRKDIRPREFIX', Vars.ETC_MAKE_CONF, False)560portdir = locateportdir(os.path.dirname(filepath), wrkdirprefix, True)561wrksrc = querymakevar('WRKSRC', portdir, True)562563relpath = getrelpath(filepath, wrksrc)564565if automatic:566patchdir = querymakevar('PATCHDIR', portdir, True)567568if os.path.isdir(patchdir):569patches.adddir(patchdir, wrksrc)570571extra_patches = querymakevar('EXTRA_PATCHES', portdir, False)572for extra_patch in extra_patches.split():573if os.path.isfile(extra_patch):574patches.addpatchfile(extra_patch, wrksrc)575576patchobj = patches.lookupbytarget(wrksrc, relpath)577if patchobj == None:578patchobj = NewPatch(patchdir, wrksrc, relpath)579patches.addpatchobj(patchobj)580581if not force and os.path.exists(patchobj.fullpath) and \582os.path.getsize(patchobj.fullpath) > 0:583try:584retval = query_yn('Target patchfile "%s" already ' \585'exists, do you want to replace it?' % \586os.path.basename(patchobj.fullpath))587except KeyboardInterrupt:588sys.exit('\nAction aborted')589# Not reached #590if retval == False:591continue592593write_msg('Generating patchfile: %s...' % \594os.path.basename(patchobj.fullpath))595596try:597retval = None598retval = patchobj.update(ignoremtime = ignoremtime)599finally:600# Following tricky magic intended to let us append \n even if601# we are going to die due to unhandled exception602if retval == None:603write_msg('OUCH!\n')604605if retval[0] == False:606write_msg('skipped (%s)\n' % retval[1])607else:608write_msg('ok\n')609610else: # automatic != True611retval = gendiff(relpath, wrksrc)612if retval[0] == False:613write_msg('WARNING: %s\n' % retval[1])614615616#617# Atomatically update all patches pointed by args (may be individual618# patchfiles, patchdirs or any directories in a portdirs). If directory argument619# is encountered, all patches that belong to the port are updated. If no620# arguments are supplied - current directory is assumed.621#622# The procedure honours last modification times of the patchfile, file from623# which diff to be generated and `EXTRACT_COOKIE' file (usually624# ${WRKDIR}/.extract_cookie) to update only those patches that are really need625# to be updated.626#627def update(args, automatic, force, ignoremtime):628if len(args) == 0:629args = './',630631for path in args:632if not os.path.exists(path):633raise IOError(errno.ENOENT, path)634# Not reached #635636patches = PatchesCollection()637638if os.path.isdir(path):639for wrkdirprefix in (querymakevar('WRKDIRPREFIX', \640Vars.ETC_MAKE_CONF, False), ''):641portdir = locateportdir(path, wrkdirprefix, False)642if portdir != '':643break644if portdir == '':645raise LocatePDirError(os.path.abspath(path))646# Not reached #647648wrksrc = querymakevar('WRKSRC', portdir, True)649patchdir = querymakevar('PATCHDIR', portdir, True)650651if os.path.isdir(patchdir):652patches.adddir(patchdir, wrksrc)653else:654continue655656elif os.path.isfile(path):657portdir = locateportdir(os.path.dirname(path), '' , True)658wrksrc = querymakevar('WRKSRC', portdir, True)659patches.addpatchfile(path, wrksrc)660661patch_cookie = querymakevar('PATCH_COOKIE', portdir, True)662if os.path.isfile(patch_cookie):663patch_cookiemtime = os.path.getmtime(patch_cookie)664else:665patch_cookiemtime = 0666667for patchobj in patches.getpatchobjs():668write_msg('Updating patchfile: %s...' % \669os.path.basename(patchobj.fullpath))670671try:672retval = None673retval = patchobj.update(patch_cookiemtime, \674ignoremtime)675finally:676if retval == None:677write_msg('OUCH!\n')678679if retval[0] == False:680write_msg('skipped (%s)\n' % retval[1])681else:682write_msg('ok\n')683684685if __name__ == '__main__':686try:687main()688except (PatchError, ECmdError, MakeVarError, LocatePDirError) as msg:689sys.exit('ERROR: ' + str(msg))690except IOError as ex:691code, msg = ex692sys.exit('ERROR: %s: %s' % (str(msg), os.strerror(code)))693694695696