Tweak print/hplip version 3.16.11_2
[dports.git] / Tools / scripts / patchtool.py
1 #!/usr/bin/env python
2 # ex:ts=4
3 #-*- mode: Fundamental; tab-width: 4; -*-
4 #
5 # patchtool.py - a tool to automate common operation with patchfiles in the
6 # FreeBSD Ports Collection.
7 #
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.
13 #
14 # Maxim Sobolev
15 # ----------------------------------------------------------------------------
16 #
17 # $FreeBSD$
18 #
19 # MAINTAINER= sobomax@FreeBSD.org <- any unapproved commits to this file are
20 #                                    highly discouraged!!!
21 #
22
23 import os, os.path, subprocess, sys, getopt, glob, errno, types
24
25 # Some global variables used as constants
26 True = 1
27 False = 0
28
29
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.
36 class Vars:
37         CVS_ID = 'FreeBSD'
38
39         DIFF_ARGS = '-du'
40         DIFF_SUFX = '.orig'
41         PATCH_PREFIX = 'patch-'
42         PATCH_IGN_SUFX = ('.orig', '.rej')
43         RCSDIFF_SUFX = ',v'
44
45         CD_CMD = 'cd'
46         DIFF_CMD = '/usr/bin/diff'
47         MAKE_CMD = '/usr/bin/make'
48         PRINTF_CMD = '/usr/bin/printf'
49         RCSDIFF_CMD = '/usr/bin/rcsdiff'
50
51         DEFAULT_MAKEFILE = 'Makefile'
52         DEV_NULL = '/dev/null'
53         ETC_MAKE_CONF = '/etc/make.conf'
54         
55         SLASH_REPL_SYMBOL = '_' # The symbol to replace '/' when auto-generating
56                                                         # patchnames
57
58
59 #
60 # Check if the supplied patch refers to a port's directory.
61 #
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)
66                 # Not reached #
67
68         try:
69                 content = os.listdir(path)
70         except OSError:
71                 return False
72
73         for file in REQ_FILES:
74                 if file not in content:
75                         return False
76         return True
77
78
79 #
80 # Traverse directory tree up from the path pointed by argument and return if
81 # root directory of a port is found.
82 #
83 def locateportdir(path, wrkdirprefix= '', strict = False):
84         # Flag to relax error checking in isportdir() function. It required when
85         # WRKDIRPREFIX is defined.
86         softisport = False
87
88         path = os.path.abspath(path)
89
90         if wrkdirprefix != '':
91                 wrkdirprefix= os.path.abspath(wrkdirprefix)
92                 commonprefix = os.path.commonprefix((path, wrkdirprefix))
93                 if commonprefix != wrkdirprefix:
94                         return ''
95                 path = path[len(wrkdirprefix):]
96                 softisport = True
97
98         while path != '/':
99                 if isportdir(path, softisport) == True:
100                         return path
101                 path = os.path.abspath(os.path.join(path, '..'))
102
103         if strict == True:
104                 raise LocatePDirError(path)
105                 # Not reached #
106         else:
107                 return ''
108
109
110 #
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...)
115 #
116 def querymakevar(varname, path = 'Makefile', strict = False, cache = {}):
117         path = os.path.abspath(path)
118
119         if cache.has_key((varname, path)) == 1:
120                 return cache[(varname, path)]
121
122         origpath = 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)
127                 # Not reached #
128
129         dir = os.path.dirname(path)
130         CMDLINE = '%s %s && %s -f %s -V %s' % (Vars.CD_CMD, dir, Vars.MAKE_CMD, \
131       path, varname)
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)
135         retval = ''
136         for line in pipe.stdout.readlines():
137                 retval = retval + line.strip() + ' '
138         retval = retval[:-1]
139         if strict == True and retval.strip() == '':
140                 raise MakeVarError(path, varname)
141                 # Not reached #
142
143         cache[(varname, origpath)] = retval
144         return retval
145
146
147 #
148 # Get a path of `path'  relatively to wrksrc. For example:
149 # path:         /foo/bar
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:
154 # path:         /foo/bar
155 # wrksrc:       /foo/baz/somefile.c
156 # getrelpath:   ../baz/somefile.c
157 #
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):]
166         adjust = ''
167         while os.path.normpath(os.path.join(wrksrc, adjust)) != '.':
168                 adjust = os.path.join(adjust, '..')
169         relpath = os.path.join(adjust, path)
170         return relpath
171
172
173 #
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
179 # "FreeBSD" cvs id.
180 #
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)
185                 # Not reached #
186
187         cmdline = ''
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
192                 path_orig = path
193                 cmdline = '%s %s %s' % (Vars.RCSDIFF_CMD, Vars.DIFF_ARGS, path)
194         else:                                                   # New file
195                 path_orig = Vars.DEV_NULL
196                 cmdline = '%s %s %s %s' % (Vars.DIFF_CMD, Vars.DIFF_ARGS, path_orig, path)
197
198         savedir = os.getcwd()
199         os.chdir(wrksrc)
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
206                 retval = False
207                 retmsg = 'no differences found between original and current ' \
208                           'version of "%s"' % fullpath
209         elif exitval == 1:  # Some differences  were  found
210                 if (outfile != ''):
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)
217                 else:
218                         sys.stdout.writelines(outbuf)
219                 retval = True
220                 retmsg = ''
221         else:               # Error occurred
222                 raise ECmdError('"%s"' % cmdline, \
223                   'external command returned non-zero error code')
224                 # Not reached #
225
226         os.chdir(savedir)
227         return (retval, retmsg)
228
229
230 #
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 '_'.
234 #
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)
240         return retval
241
242
243 #
244 # Write a specified message to stderr.
245 #
246 def write_msg(message):
247         if type(message) == types.StringType:
248                 message = message,
249         sys.stderr.writelines(message)
250
251
252 #
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>
255 #
256 def query_yn(message, default = False):
257         while True:
258                 if default == True:
259                         yn = 'Y/n'
260                 elif default == False:
261                         yn = 'y/N'
262                 else:
263                         yn = 'Y/N'
264
265                 reply = raw_input('%s [%s]: ' % (message, yn))
266
267                 if reply == 'y' or reply == 'Y':
268                         return True
269                 elif reply == 'n' or reply == 'N':
270                         return False
271                 elif reply == '' and default in (True, False):
272                         return default
273                 print 'Wrong answer "%s", please try again' % reply
274         return default
275
276
277 #
278 # Print optional message and usage information and exit with specified exit
279 # code.
280 #
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)))
287         sys.exit(code)
288
289
290 #
291 # Simple custom exception
292 #
293 class MyError(Exception):
294         msg = 'error'
295
296         def __init__(self, file, msg=''):
297                 self.file = file
298                 if msg != '':
299                         self.msg = msg
300
301         def __str__(self):
302                         return '%s: %s' % (self.file, self.msg)
303
304
305 #
306 # Error parsing patchfile
307 #
308 class PatchError(MyError):
309         msg = 'corrupt patchfile, or not patchfile at all'
310
311
312 #
313 # Error executing external command
314 #
315 class ECmdError(MyError):
316         pass
317
318
319 #
320 # Error getting value of makefile variable
321 #
322 class MakeVarError(MyError):
323         def __init__(self, file, makevar, msg=''):
324                 self.file = file
325                 if msg != '':
326                         self.msg = msg
327                 else:
328                         self.msg = 'can\'t get %s value' % makevar
329
330
331 #
332 # Error locating portdir
333 #
334 class LocatePDirError(MyError):
335         msg = 'can\'t locate portdir'
336
337
338 class Patch:
339         fullpath = ''
340         minus3file = ''
341         plus3file = ''
342         wrksrc = ''
343         patchmtime = 0
344         targetmtime = 0
345
346         def __init__(self, path, wrksrc):
347                 MINUS3_DELIM = '--- '
348                 PLUS3_DELIM = '+++ '
349
350                 path = os.path.abspath(path)
351                 if not os.path.isfile(path):
352                         raise IOError(errno.ENOENT, path)
353                         # Not reached #
354
355                 self.fullpath = path
356                 filedes = open(path)
357
358                 for line in filedes.readlines():
359                         if self.minus3file == '':
360                                 if line[:len(MINUS3_DELIM)] == MINUS3_DELIM:
361                                         lineparts = line.split()
362                                         try:
363                                                 self.minus3file = lineparts[1]
364                                         except IndexError:
365                                                 raise PatchError(path)
366                                                 # Not reached #
367                                         continue
368                         elif line[:len(PLUS3_DELIM)] == PLUS3_DELIM:
369                                 lineparts = line.split()
370                                 try:
371                                         self.plus3file = lineparts[1]
372                                 except IndexError:
373                                         raise PatchError(path)
374                                         # Not reached #
375                                 break
376
377                 filedes.close()
378
379                 if self.minus3file == '' or self.plus3file == '':
380                         raise PatchError(path)
381                         # Not reached #
382
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)
388                 else:
389                         self.targetmtime = 0
390
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)
395                         # Not reached #
396
397                 patchdir = os.path.dirname(self.fullpath)
398                 if not os.path.isdir(patchdir):
399                         os.mkdir(patchdir)
400
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)
408                 else:
409                         retval = (False, 'patch is already up to date')
410                 return retval
411
412
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
419                 self.patchmtime = 0
420                 plus3file = os.path.join(self.wrksrc, self.plus3file)
421                 if os.path.isfile(plus3file):
422                         self.targetmtime = os.path.getmtime(plus3file)
423                 else:
424                         self.targetmtime = 0
425
426
427 class PatchesCollection:
428         patches = {}
429
430         def __init__(self):
431                 self.patches = {}
432                 pass
433
434         def adddir(self, patchdir, wrksrc):
435                 if not os.path.isdir(patchdir):
436                         raise IOError(errno.ENOENT, patchdir)
437                         # Not reached #
438
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)
443                                         break
444                         else:
445                                 self.addpatchfile(filename, wrksrc)
446
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))
451
452         def addpatchobj(self, patchobj):
453                 self.patches[patchobj.fullpath] = patchobj
454
455         def lookupbyname(self, path):
456                 path = os.path.abspath(path)
457                 if self.patches.has_key(path):
458                         return self.patches[path]
459                 return None
460
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:
465                                 return patch
466                 return None
467
468         def getpatchobjs(self):
469                 return self.patches.values()
470
471
472 #
473 # Resolve all symbolic links in the given path to a file
474 #
475 def truepath(path):
476         if not os.path.isfile(path):
477                 raise IOError(errno.ENOENT, path)
478
479         result = ''
480         while len(path) > 0:
481                 path, lastcomp = os.path.split(path)
482                 if len(lastcomp) == 0:
483                         lastcomp = path
484                         path = ''
485                 result = os.path.join(lastcomp, result)
486                 if len(path) == 0:
487                         break
488                 if os.path.islink(path):
489                         linkto = os.path.normpath(os.readlink(path))
490                         if linkto[0] != '/':
491                                 path = os.path.join(path, linkto)
492                         else:
493                                 path = linkto
494         return result[:-1]
495
496
497 def main():
498         try:
499                 opts, args = getopt.getopt(sys.argv[1:], 'afui')
500         except getopt.GetoptError, msg:
501                 usage(2, msg)
502
503         automatic = False
504         force = False
505         mode = generate
506         ignoremtime = False
507
508         for o, a in opts:
509                 if o == '-a':
510                         automatic = True
511                 elif o == '-f':
512                         force = True
513                 elif o == '-u':
514                         mode = update
515                 elif o == '-i':
516                         ignoremtime = True
517                 else:
518                         usage(2)
519
520         # Allow user to override internal constants
521         for varname in dir(Vars):
522                 if varname[:2] == '__' and varname[-2:] == '__':
523                         continue
524                 try:
525                         value = os.environ['PT_' + varname]
526                         setattr(Vars, varname, value)
527                 except KeyError:
528                         pass
529
530         mode(args, automatic, force, ignoremtime)
531
532         sys.exit(0)
533
534
535 #
536 # Display a diff or generate patchfile for the files pointed out by args.
537 #
538 def generate(args, automatic, force, ignoremtime):
539         if len(args) == 0:
540                 usage(2, "ERROR: no input files specified")
541
542         patches = PatchesCollection()
543
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)]
548                                 break
549                 if not os.path.isfile(filepath):
550                         raise IOError(errno.ENOENT, filepath)
551                         # Not reached #
552
553                 filepath = truepath(filepath)
554
555                 wrkdirprefix = querymakevar('WRKDIRPREFIX', Vars.ETC_MAKE_CONF, False)
556                 portdir = locateportdir(os.path.dirname(filepath), wrkdirprefix, True)
557                 wrksrc = querymakevar('WRKSRC', portdir, True)
558
559                 relpath = getrelpath(filepath, wrksrc)
560
561                 if automatic:
562                         patchdir = querymakevar('PATCHDIR', portdir, True)
563
564                         if os.path.isdir(patchdir):
565                                 patches.adddir(patchdir, wrksrc)
566
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)
571
572                         patchobj = patches.lookupbytarget(wrksrc, relpath)
573                         if patchobj == None:
574                                 patchobj = NewPatch(patchdir, wrksrc, relpath)
575                                 patches.addpatchobj(patchobj)
576
577                         if not force and os.path.exists(patchobj.fullpath) and \
578                           os.path.getsize(patchobj.fullpath) > 0:
579                                 try:
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')
585                                         # Not reached #
586                                 if retval == False:
587                                         continue
588
589                         write_msg('Generating patchfile: %s...' % \
590                           os.path.basename(patchobj.fullpath))
591
592                         try:
593                                 retval = None
594                                 retval = patchobj.update(ignoremtime = ignoremtime)
595                         finally:
596                                 # Following tricky magic intended to let us append \n even if
597                                 # we are going to die due to unhandled exception
598                                 if retval == None:
599                                         write_msg('OUCH!\n')
600
601                         if retval[0] == False:
602                                 write_msg('skipped (%s)\n' % retval[1])
603                         else:
604                                 write_msg('ok\n')
605
606                 else:   # automatic != True
607                         retval = gendiff(relpath, wrksrc)
608                         if retval[0] == False:
609                                 write_msg('WARNING: %s\n' % retval[1])
610
611
612 #
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.
617 #
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
621 # to be updated.
622 #
623 def update(args, automatic, force, ignoremtime):
624         if len(args) == 0:
625                 args = './',
626
627         for path in args:
628                 if not os.path.exists(path):
629                         raise IOError(errno.ENOENT, path)
630                         # Not reached #
631
632                 patches = PatchesCollection()
633
634                 if os.path.isdir(path):
635                         for wrkdirprefix in (querymakevar('WRKDIRPREFIX', \
636               Vars.ETC_MAKE_CONF, False), ''):
637                                 portdir = locateportdir(path, wrkdirprefix, False)
638                                 if portdir != '':
639                                         break
640                         if portdir == '':
641                                 raise LocatePDirError(os.path.abspath(path))
642                                 # Not reached #
643
644                         wrksrc = querymakevar('WRKSRC', portdir, True)
645                         patchdir = querymakevar('PATCHDIR', portdir, True)
646
647                         if os.path.isdir(patchdir):
648                                 patches.adddir(patchdir, wrksrc)
649                         else:
650                                 continue
651
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)
656
657                 patch_cookie = querymakevar('PATCH_COOKIE', portdir, True)
658                 if os.path.isfile(patch_cookie):
659                         patch_cookiemtime = os.path.getmtime(patch_cookie)
660                 else:
661                         patch_cookiemtime = 0
662
663                 for patchobj in patches.getpatchobjs():
664                         write_msg('Updating patchfile: %s...' % \
665                           os.path.basename(patchobj.fullpath))
666
667                         try:
668                                 retval = None
669                                 retval = patchobj.update(patch_cookiemtime, \
670                                   ignoremtime)
671                         finally:
672                                 if retval == None:
673                                         write_msg('OUCH!\n')
674
675                         if retval[0] == False:
676                                 write_msg('skipped (%s)\n' % retval[1])
677                         else:
678                                 write_msg('ok\n')
679
680
681 if __name__ == '__main__':
682         try:
683                 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)))
688