3 #-*- mode: Fundamental; tab-width: 4; -*-
5 # patchtool.py - a tool to automate common operation with patchfiles in the
6 # FreeBSD Ports Collection.
8 # ----------------------------------------------------------------------------
9 # "THE BEER-WARE LICENSE" (Revision 42, (c) Poul-Henning Kamp):
10 # Maxim Sobolev <sobomax@FreeBSD.org> wrote this file. As long as you retain
11 # this notice you can do whatever you want with this stuff. If we meet some
12 # day, and you think this stuff is worth it, you can buy me a beer in return.
15 # ----------------------------------------------------------------------------
19 # MAINTAINER= sobomax@FreeBSD.org <- any unapproved commits to this file are
20 # highly discouraged!!!
23 import os, os.path, subprocess, sys, getopt, glob, errno, types
25 # Some global variables used as constants
30 # Tweakable global variables. User is able to override any of these by setting
31 # appropriate environment variable prefixed by `PT_', eg:
32 # $ export PT_CVS_ID="FooOS"
33 # $ export PT_DIFF_CMD="/usr/local/bin/mydiff"
34 # will force script to use "FooOS" as a CVS_ID instead of "FreeBSD" and
35 # "/usr/local/bin/mydiff" as a command to generate diffs.
41 PATCH_PREFIX = 'patch-'
42 PATCH_IGN_SUFX = ('.orig', '.rej')
46 DIFF_CMD = '/usr/bin/diff'
47 MAKE_CMD = '/usr/bin/make'
48 PRINTF_CMD = '/usr/bin/printf'
49 RCSDIFF_CMD = '/usr/bin/rcsdiff'
51 DEFAULT_MAKEFILE = 'Makefile'
52 DEV_NULL = '/dev/null'
53 ETC_MAKE_CONF = '/etc/make.conf'
55 SLASH_REPL_SYMBOL = '_' # The symbol to replace '/' when auto-generating
60 # Check if the supplied patch refers to a port's directory.
62 def isportdir(path, soft = False):
63 REQ_FILES = ('Makefile', 'pkg-descr', 'distinfo')
64 if not os.path.isdir(path) and soft != True:
65 raise IOError(errno.ENOENT, path)
69 content = os.listdir(path)
73 for file in REQ_FILES:
74 if file not in content:
80 # Traverse directory tree up from the path pointed by argument and return if
81 # root directory of a port is found.
83 def locateportdir(path, wrkdirprefix= '', strict = False):
84 # Flag to relax error checking in isportdir() function. It required when
85 # WRKDIRPREFIX is defined.
88 path = os.path.abspath(path)
90 if wrkdirprefix != '':
91 wrkdirprefix= os.path.abspath(wrkdirprefix)
92 commonprefix = os.path.commonprefix((path, wrkdirprefix))
93 if commonprefix != wrkdirprefix:
95 path = path[len(wrkdirprefix):]
99 if isportdir(path, softisport) == True:
101 path = os.path.abspath(os.path.join(path, '..'))
104 raise LocatePDirError(path)
111 # Get value of a make(1) variable called varname. Optionally maintain a cache
112 # for resolved varname:makepath pairs to speed-up operation if the same variable
113 # from the exactly same file is requested repeatedly (invocation of make(1) is
114 # very expensive operation...)
116 def querymakevar(varname, path = 'Makefile', strict = False, cache = {}):
117 path = os.path.abspath(path)
119 if cache.has_key((varname, path)) == 1:
120 return cache[(varname, path)]
123 if os.path.isdir(path):
124 path = os.path.join(path, Vars.DEFAULT_MAKEFILE)
125 if not os.path.isfile(path):
126 raise IOError(errno.ENOENT, path)
129 dir = os.path.dirname(path)
130 CMDLINE = '%s %s && %s -f %s -V %s' % (Vars.CD_CMD, dir, Vars.MAKE_CMD, \
132 devnull = open('/dev/null', 'a')
133 pipe = subprocess.Popen(CMDLINE, shell = True, stdin = subprocess.PIPE, \
134 stdout = subprocess.PIPE, stderr = devnull, close_fds = True)
136 for line in pipe.stdout.readlines():
137 retval = retval + line.strip() + ' '
139 if strict == True and retval.strip() == '':
140 raise MakeVarError(path, varname)
143 cache[(varname, origpath)] = retval
148 # Get a path of `path' relatively to wrksrc. For example:
150 # wrksrc: /foo/bar/baz/somefile.c
151 # getrelpath: baz/somefile.c
152 # Most of the code here is to handle cases when ../ operation is required to
153 # reach wrksrc from path, for example:
155 # wrksrc: /foo/baz/somefile.c
156 # getrelpath: ../baz/somefile.c
158 def getrelpath(path, wrksrc):
159 path = os.path.abspath(path)
160 wrksrc = os.path.abspath(wrksrc) + '/'
161 commonpart = os.path.commonprefix((path, wrksrc))
162 while commonpart[-1:] != '/':
163 commonpart = commonpart[:-1]
164 path = path[len(commonpart):]
165 wrksrc = wrksrc[len(commonpart):]
167 while os.path.normpath(os.path.join(wrksrc, adjust)) != '.':
168 adjust = os.path.join(adjust, '..')
169 relpath = os.path.join(adjust, path)
174 # Generate a diff between saved and current versions of the file pointed by the
175 # wrksrc+path. Apply heuristics to locate saved version of the file in question
176 # and if it fails assume that file is new, so /dev/null is to be used as
177 # original file. Optionally save generated patch into `outfile' instead of
178 # dumping it to stdout. Generated patches automatically being tagged with
181 def gendiff(path, wrksrc, outfile = ''):
182 fullpath = os.path.join(wrksrc, path)
183 if not os.path.isfile(fullpath):
184 raise IOError(errno.ENOENT, fullpath)
188 if os.path.isfile(fullpath + Vars.DIFF_SUFX): # Normal diff
189 path_orig = path + Vars.DIFF_SUFX
190 cmdline = '%s %s %s %s' % (Vars.DIFF_CMD, Vars.DIFF_ARGS, path_orig, path)
191 elif os.path.isfile(fullpath + Vars.RCSDIFF_SUFX): # RCS diff
193 cmdline = '%s %s %s' % (Vars.RCSDIFF_CMD, Vars.DIFF_ARGS, path)
195 path_orig = Vars.DEV_NULL
196 cmdline = '%s %s %s %s' % (Vars.DIFF_CMD, Vars.DIFF_ARGS, path_orig, path)
198 savedir = os.getcwd()
200 devnull = open('/dev/null', 'a')
201 pipe = subprocess.Popen(cmdline, shell = True, stdin = subprocess.PIPE, \
202 stdout = subprocess.PIPE, stderr = devnull, close_fds = True)
203 outbuf = pipe.stdout.readlines()
204 exitval = pipe.wait()
205 if exitval == 0: # No differences were found
207 retmsg = 'no differences found between original and current ' \
208 'version of "%s"' % fullpath
209 elif exitval == 1: # Some differences were found
211 outbuf[0] = '--- %s\n' % path_orig
212 outbuf[1] = '+++ %s\n' % path
213 outbuf.insert(0, '\n')
214 outbuf.insert(0, '$%s$\n' % Vars.CVS_ID)
215 outbuf.insert(0, '\n')
216 open(outfile, 'w').writelines(outbuf)
218 sys.stdout.writelines(outbuf)
221 else: # Error occurred
222 raise ECmdError('"%s"' % cmdline, \
223 'external command returned non-zero error code')
227 return (retval, retmsg)
231 # Automatically generate a name for a patch based on its path relative to
232 # wrksrc. Use simple scheme to ensure 1-to-1 mapping between path and
233 # patchname - replace all '_' with '__' and all '/' with '_'.
235 def makepatchname(path, patchdir = ''):
236 SRS = Vars.SLASH_REPL_SYMBOL
237 retval = Vars.PATCH_PREFIX + \
238 path.replace(SRS, SRS + SRS).replace('/', SRS)
239 retval = os.path.join(patchdir, retval)
244 # Write a specified message to stderr.
246 def write_msg(message):
247 if type(message) == types.StringType:
249 sys.stderr.writelines(message)
253 # Print specified message to stdout and ask user [y/N]?. Optionally allow
254 # specify default answer, i.e. return value if user typed only <cr>
256 def query_yn(message, default = False):
260 elif default == False:
265 reply = raw_input('%s [%s]: ' % (message, yn))
267 if reply == 'y' or reply == 'Y':
269 elif reply == 'n' or reply == 'N':
271 elif reply == '' and default in (True, False):
273 print 'Wrong answer "%s", please try again' % reply
278 # Print optional message and usage information and exit with specified exit
281 def usage(code, msg = ''):
282 myname = os.path.basename(sys.argv[0])
283 write_msg((str(msg), """
284 Usage: %s [-afi] file ...
285 %s -u [-i] [patchfile|patchdir ...]
286 """ % (myname, myname)))
291 # Simple custom exception
293 class MyError(Exception):
296 def __init__(self, file, msg=''):
302 return '%s: %s' % (self.file, self.msg)
306 # Error parsing patchfile
308 class PatchError(MyError):
309 msg = 'corrupt patchfile, or not patchfile at all'
313 # Error executing external command
315 class ECmdError(MyError):
320 # Error getting value of makefile variable
322 class MakeVarError(MyError):
323 def __init__(self, file, makevar, msg=''):
328 self.msg = 'can\'t get %s value' % makevar
332 # Error locating portdir
334 class LocatePDirError(MyError):
335 msg = 'can\'t locate portdir'
346 def __init__(self, path, wrksrc):
347 MINUS3_DELIM = '--- '
350 path = os.path.abspath(path)
351 if not os.path.isfile(path):
352 raise IOError(errno.ENOENT, path)
358 for line in filedes.readlines():
359 if self.minus3file == '':
360 if line[:len(MINUS3_DELIM)] == MINUS3_DELIM:
361 lineparts = line.split()
363 self.minus3file = lineparts[1]
365 raise PatchError(path)
368 elif line[:len(PLUS3_DELIM)] == PLUS3_DELIM:
369 lineparts = line.split()
371 self.plus3file = lineparts[1]
373 raise PatchError(path)
379 if self.minus3file == '' or self.plus3file == '':
380 raise PatchError(path)
383 self.wrksrc = os.path.abspath(wrksrc)
384 self.patchmtime = os.path.getmtime(self.fullpath)
385 plus3file = os.path.join(self.wrksrc, self.plus3file)
386 if os.path.isfile(plus3file):
387 self.targetmtime = os.path.getmtime(plus3file)
391 def update(self, patch_cookiemtime = 0, ignoremtime = False):
392 targetfile = os.path.join(self.wrksrc, self.plus3file)
393 if not os.path.isfile(targetfile):
394 raise IOError(errno.ENOENT, targetfile)
397 patchdir = os.path.dirname(self.fullpath)
398 if not os.path.isdir(patchdir):
401 if ignoremtime == True or self.patchmtime == 0 or \
402 self.targetmtime == 0 or \
403 (self.patchmtime < self.targetmtime and \
404 patch_cookiemtime < self.targetmtime):
405 retval = gendiff(self.plus3file, self.wrksrc, self.fullpath)
406 if retval[0] == True:
407 self.patchmtime = os.path.getmtime(self.fullpath)
409 retval = (False, 'patch is already up to date')
413 class NewPatch(Patch):
414 def __init__(self, patchdir, wrksrc, relpath):
415 self.fullpath = makepatchname(relpath, os.path.abspath(patchdir))
416 self.wrksrc = os.path.abspath(wrksrc)
417 self.plus3file = relpath
418 self.minus3file = relpath
420 plus3file = os.path.join(self.wrksrc, self.plus3file)
421 if os.path.isfile(plus3file):
422 self.targetmtime = os.path.getmtime(plus3file)
427 class PatchesCollection:
434 def adddir(self, patchdir, wrksrc):
435 if not os.path.isdir(patchdir):
436 raise IOError(errno.ENOENT, patchdir)
439 for filename in glob.glob(os.path.join(patchdir, Vars.PATCH_PREFIX + '*')):
440 for sufx in Vars.PATCH_IGN_SUFX:
441 if filename[-len(sufx):] == sufx:
442 write_msg('WARNING: patchfile "%s" ignored\n' % filename)
445 self.addpatchfile(filename, wrksrc)
447 def addpatchfile(self, path, wrksrc):
448 path = os.path.abspath(path)
449 if not self.patches.has_key(path):
450 self.addpatchobj(Patch(path, wrksrc))
452 def addpatchobj(self, patchobj):
453 self.patches[patchobj.fullpath] = patchobj
455 def lookupbyname(self, path):
456 path = os.path.abspath(path)
457 if self.patches.has_key(path):
458 return self.patches[path]
461 def lookupbytarget(self, wrksrc, relpath):
462 wrksrc = os.path.abspath(wrksrc)
463 for patch in self.patches.values():
464 if wrksrc == patch.wrksrc and relpath == patch.plus3file:
468 def getpatchobjs(self):
469 return self.patches.values()
473 # Resolve all symbolic links in the given path to a file
476 if not os.path.isfile(path):
477 raise IOError(errno.ENOENT, path)
481 path, lastcomp = os.path.split(path)
482 if len(lastcomp) == 0:
485 result = os.path.join(lastcomp, result)
488 if os.path.islink(path):
489 linkto = os.path.normpath(os.readlink(path))
491 path = os.path.join(path, linkto)
499 opts, args = getopt.getopt(sys.argv[1:], 'afui')
500 except getopt.GetoptError, msg:
520 # Allow user to override internal constants
521 for varname in dir(Vars):
522 if varname[:2] == '__' and varname[-2:] == '__':
525 value = os.environ['PT_' + varname]
526 setattr(Vars, varname, value)
530 mode(args, automatic, force, ignoremtime)
536 # Display a diff or generate patchfile for the files pointed out by args.
538 def generate(args, automatic, force, ignoremtime):
540 usage(2, "ERROR: no input files specified")
542 patches = PatchesCollection()
544 for filepath in args:
545 for suf in Vars.RCSDIFF_SUFX, Vars.DIFF_SUFX:
546 if filepath.endswith(suf):
547 filepath = filepath[:-len(suf)]
549 if not os.path.isfile(filepath):
550 raise IOError(errno.ENOENT, filepath)
553 filepath = truepath(filepath)
555 wrkdirprefix = querymakevar('WRKDIRPREFIX', Vars.ETC_MAKE_CONF, False)
556 portdir = locateportdir(os.path.dirname(filepath), wrkdirprefix, True)
557 wrksrc = querymakevar('WRKSRC', portdir, True)
559 relpath = getrelpath(filepath, wrksrc)
562 patchdir = querymakevar('PATCHDIR', portdir, True)
564 if os.path.isdir(patchdir):
565 patches.adddir(patchdir, wrksrc)
567 extra_patches = querymakevar('EXTRA_PATCHES', portdir, False)
568 for extra_patch in extra_patches.split():
569 if os.path.isfile(extra_patch):
570 patches.addpatchfile(extra_patch, wrksrc)
572 patchobj = patches.lookupbytarget(wrksrc, relpath)
574 patchobj = NewPatch(patchdir, wrksrc, relpath)
575 patches.addpatchobj(patchobj)
577 if not force and os.path.exists(patchobj.fullpath) and \
578 os.path.getsize(patchobj.fullpath) > 0:
580 retval = query_yn('Target patchfile "%s" already ' \
581 'exists, do you want to replace it?' % \
582 os.path.basename(patchobj.fullpath))
583 except KeyboardInterrupt:
584 sys.exit('\nAction aborted')
589 write_msg('Generating patchfile: %s...' % \
590 os.path.basename(patchobj.fullpath))
594 retval = patchobj.update(ignoremtime = ignoremtime)
596 # Following tricky magic intended to let us append \n even if
597 # we are going to die due to unhandled exception
601 if retval[0] == False:
602 write_msg('skipped (%s)\n' % retval[1])
606 else: # automatic != True
607 retval = gendiff(relpath, wrksrc)
608 if retval[0] == False:
609 write_msg('WARNING: %s\n' % retval[1])
613 # Atomatically update all patches pointed by args (may be individual
614 # patchfiles, patchdirs or any directories in a portdirs). If directory argument
615 # is encountered, all patches that belong to the port are updated. If no
616 # arguments are supplied - current directory is assumed.
618 # The procedure honours last modification times of the patchfile, file from
619 # which diff to be generated and `EXTRACT_COOKIE' file (usually
620 # ${WRKDIR}/.extract_cookie) to update only those patches that are really need
623 def update(args, automatic, force, ignoremtime):
628 if not os.path.exists(path):
629 raise IOError(errno.ENOENT, path)
632 patches = PatchesCollection()
634 if os.path.isdir(path):
635 for wrkdirprefix in (querymakevar('WRKDIRPREFIX', \
636 Vars.ETC_MAKE_CONF, False), ''):
637 portdir = locateportdir(path, wrkdirprefix, False)
641 raise LocatePDirError(os.path.abspath(path))
644 wrksrc = querymakevar('WRKSRC', portdir, True)
645 patchdir = querymakevar('PATCHDIR', portdir, True)
647 if os.path.isdir(patchdir):
648 patches.adddir(patchdir, wrksrc)
652 elif os.path.isfile(path):
653 portdir = locateportdir(os.path.dirname(path), '' , True)
654 wrksrc = querymakevar('WRKSRC', portdir, True)
655 patches.addpatchfile(path, wrksrc)
657 patch_cookie = querymakevar('PATCH_COOKIE', portdir, True)
658 if os.path.isfile(patch_cookie):
659 patch_cookiemtime = os.path.getmtime(patch_cookie)
661 patch_cookiemtime = 0
663 for patchobj in patches.getpatchobjs():
664 write_msg('Updating patchfile: %s...' % \
665 os.path.basename(patchobj.fullpath))
669 retval = patchobj.update(patch_cookiemtime, \
675 if retval[0] == False:
676 write_msg('skipped (%s)\n' % retval[1])
681 if __name__ == '__main__':
684 except (PatchError, ECmdError, MakeVarError, LocatePDirError), msg:
685 sys.exit('ERROR: ' + str(msg))
686 except IOError, (code, msg):
687 sys.exit('ERROR: %s: %s' % (str(msg), os.strerror(code)))