Enable BytesWarnings.
[portage.git] / bin / dispatch-conf
index 309bd0db1372d4dbbbe5a0aa9911b7dcd29ff990..6a818bf3bc7f1693b5c29327f4b14d054fbce8d0 100755 (executable)
@@ -1,7 +1,6 @@
-#!/usr/bin/python -O
-# Copyright 1999-2006 Gentoo Foundation
+#!/usr/bin/python -bbO
+# Copyright 1999-2014 Gentoo Foundation
 # Distributed under the terms of the GNU General Public License v2
-# $Id$
 
 #
 # dispatch-conf -- Integrate modified configs, post-emerge
 #  dialog menus
 #
 
-if not hasattr(__builtins__, "set"):
-       from sets import Set as set
+from __future__ import print_function
 
-from stat import *
-from random import *
-import atexit, commands, os, re, shutil, stat, sys
-try:
-    import portage
-except ImportError:
-    sys.path.insert(0, "/usr/lib/portage/pym")
-    import portage
+from stat import ST_GID, ST_MODE, ST_UID
+from random import random
+import atexit, re, shutil, stat, sys
+from os import path as osp
+pym_path = osp.join(osp.dirname(osp.dirname(osp.realpath(__file__))), "pym")
+sys.path.insert(0, pym_path)
+import portage
+portage._internal_caller = True
+from portage import os
+from portage import _unicode_decode
+from portage.dispatch_conf import diffstatusoutput
+from portage.process import find_binary, spawn
 
-import dispatch_conf
-from portage.process import find_binary
-
-FIND_EXTANT_CONFIGS  = "find '%s' %s -iname '._cfg????_%s' ! -iname '.*~' ! -iname '.*.bak'"
-DIFF_CONTENTS        = 'diff -Nu %s %s'
-DIFF_CVS_INTERP      = 'diff -Nu %s %s | grep "^[+-][^+-]" | grep -v "# .Header:.*"'
-DIFF_WSCOMMENTS      = 'diff -Nu %s %s | grep "^[+-][^+-]" | grep -v "^[-+]#" | grep -v "^[-+][:space:]*$"'
+FIND_EXTANT_CONFIGS  = "find '%s' %s -name '._cfg????_%s' ! -name '.*~' ! -iname '.*.bak' -print"
+DIFF_CONTENTS        = "diff -Nu '%s' '%s'"
 
 # We need a secure scratch dir and python does silly verbose errors on the use of tempnam
-oldmask = os.umask(0077)
+oldmask = os.umask(0o077)
 SCRATCH_DIR = None
 while SCRATCH_DIR is None:
     try:
@@ -49,7 +46,7 @@ while SCRATCH_DIR is None:
             continue
         os.mkdir(mydir)
         SCRATCH_DIR = mydir
-    except OSError, e:
+    except OSError as e:
         if e.errno != 17:
             raise
 os.umask(oldmask)
@@ -59,7 +56,21 @@ def cleanup(mydir=SCRATCH_DIR):
     shutil.rmtree(mydir)
 atexit.register(cleanup)
 
-MANDATORY_OPTS  = [ 'archive-dir', 'diff', 'replace-cvs', 'replace-wscomments', 'merge' ]
+MANDATORY_OPTS = [ 'archive-dir', 'diff', 'replace-cvs', 'replace-wscomments', 'merge' ]
+
+def cmd_var_is_valid(cmd):
+    """
+    Return true if the first whitespace-separated token contained
+    in cmd is an executable file, false otherwise.
+    """
+    cmd = portage.util.shlex_split(cmd)
+    if not cmd:
+        return False
+
+    if os.path.isabs(cmd[0]):
+        return os.access(cmd[0], os.EX_OK)
+
+    return find_binary(cmd[0]) is not None
 
 class dispatch:
     options = {}
@@ -68,49 +79,75 @@ class dispatch:
         confs = []
         count = 0
 
-
+        config_root = portage.settings["EPREFIX"] or os.sep
         self.options = portage.dispatch_conf.read_config(MANDATORY_OPTS)
 
-        if self.options.has_key("log-file"):
+        if "log-file" in self.options:
             if os.path.isfile(self.options["log-file"]):
                 shutil.copy(self.options["log-file"], self.options["log-file"] + '.old')
             if os.path.isfile(self.options["log-file"]) \
                or not os.path.exists(self.options["log-file"]):
                 open(self.options["log-file"], 'w').close() # Truncate it
-                os.chmod(self.options["log-file"], 0600)
+                os.chmod(self.options["log-file"], 0o600)
         else:
             self.options["log-file"] = "/dev/null"
 
+        pager = self.options.get("pager")
+        if pager is None or not cmd_var_is_valid(pager):
+            pager = os.environ.get("PAGER")
+            if pager is None or not cmd_var_is_valid(pager):
+                pager = "cat"
+
+        pager_basename = os.path.basename(portage.util.shlex_split(pager)[0])
+        if pager_basename == "less":
+            less_opts = self.options.get("less-opts")
+            if less_opts is not None and less_opts.strip():
+                pager += " " + less_opts
+
+        if pager_basename == "cat":
+            pager = ""
+        else:
+            pager = " | " + pager
+
         #
         # Build list of extant configs
         #
 
-        for path in config_paths.split ():
-            path = portage.normalize_path(path)
+        for path in config_paths:
+            path = portage.normalize_path(
+                 os.path.join(config_root, path.lstrip(os.sep)))
             try:
                 mymode = os.stat(path).st_mode
             except OSError:
                 continue
             basename = "*"
-            find_opts = ""
+            find_opts = "-name '.*' -type d -prune -o"
             if not stat.S_ISDIR(mymode):
                 path, basename = os.path.split(path)
                 find_opts = "-maxdepth 1"
 
-            confs += self.massage(os.popen(FIND_EXTANT_CONFIGS % (path, find_opts, basename)).readlines())
+            with os.popen(FIND_EXTANT_CONFIGS %
+                (path, find_opts, basename)) as proc:
+                confs += self.massage(proc.readlines())
 
         if self.options['use-rcs'] == 'yes':
             for rcs_util in ("rcs", "ci", "co", "rcsmerge"):
                 if not find_binary(rcs_util):
-                    print >> sys.stderr, \
-                        'dispatch-conf: Error finding all RCS utils and " + \
-                        "use-rcs=yes in config; fatal'
+                    print('dispatch-conf: Error finding all RCS utils and " + \
+                        "use-rcs=yes in config; fatal', file=sys.stderr)
                     return False
 
 
         # config file freezing support
         frozen_files = set(self.options.get("frozen-files", "").split())
         auto_zapped = []
+        protect_obj = portage.util.ConfigProtect(
+            config_root, config_paths,
+            portage.util.shlex_split(
+            portage.settings.get('CONFIG_PROTECT_MASK', '')))
+
+        def diff(file1, file2):
+            return diffstatusoutput(DIFF_CONTENTS, file1, file2)
 
         #
         # Remove new configs identical to current
@@ -128,11 +165,11 @@ class dispatch:
             else:
                 mrgfail = portage.dispatch_conf.file_archive(archive, conf['current'], conf['new'], mrgconf)
             if os.path.exists(archive + '.dist'):
-                unmodified = len(commands.getoutput(DIFF_CONTENTS % (conf['current'], archive + '.dist'))) == 0
+                unmodified = len(diff(conf['current'], archive + '.dist')[1]) == 0
             else:
                 unmodified = 0
             if os.path.exists(mrgconf):
-                if mrgfail or len(commands.getoutput(DIFF_CONTENTS % (conf['new'], mrgconf))) == 0:
+                if mrgfail or len(diff(conf['new'], mrgconf)[1]) == 0:
                     os.unlink(mrgconf)
                     newconf = conf['new']
                 else:
@@ -140,18 +177,37 @@ class dispatch:
             else:
                 newconf = conf['new']
 
-            mystatus, myoutput = commands.getstatusoutput(
-                DIFF_CONTENTS  % (conf ['current'], newconf))
-            same_file = 0 == len(myoutput)
+            if newconf == mrgconf and \
+                self.options.get('ignore-previously-merged') != 'yes' and \
+                os.path.exists(archive+'.dist') and \
+                len(diff(archive+'.dist', conf['new'])[1]) == 0:
+                # The current update is identical to the archived .dist
+                # version that has previously been merged.
+                os.unlink(mrgconf)
+                newconf = conf['new']
+
+            mystatus, myoutput = diff(conf['current'], newconf)
+            myoutput_len = len(myoutput)
+            same_file = 0 == myoutput_len
             if mystatus >> 8 == 2:
                 # Binary files differ
                 same_cvs = False
                 same_wsc = False
             else:
-                same_cvs = 0 == len(commands.getoutput(
-                    DIFF_CVS_INTERP % (conf ['current'], newconf)))
-                same_wsc = 0 == len(commands.getoutput(
-                    DIFF_WSCOMMENTS % (conf ['current'], newconf)))
+                # Extract all the normal diff lines (ignore the headers).
+                mylines = re.findall('^[+-][^\n+-].*$', myoutput, re.MULTILINE)
+
+                # Filter out all the cvs headers
+                cvs_header = re.compile('# [$]Header:')
+                cvs_lines = list(filter(cvs_header.search, mylines))
+                same_cvs = len(mylines) == len(cvs_lines)
+
+                # Filter out comments and whitespace-only changes.
+                # Note: be nice to also ignore lines that only differ in whitespace...
+                wsc_lines = []
+                for x in ['^[-+]\s*#', '^[-+]\s*$']:
+                   wsc_lines += list(filter(re.compile(x).match, mylines))
+                same_wsc = len(mylines) == len(wsc_lines)
 
             # Do options permit?
             same_cvs = same_cvs and self.options['replace-cvs'] == 'yes'
@@ -176,7 +232,8 @@ class dispatch:
                 except OSError:
                     pass
                 return False
-            elif unmodified or same_cvs or same_wsc or conf ['dir'] in portage.settings ['CONFIG_PROTECT_MASK'].split ():
+            elif unmodified or same_cvs or same_wsc or \
+                not protect_obj.isprotected(conf['current']):
                 self.replace(newconf, conf['current'])
                 self.post_process(conf['current'])
                 if newconf == mrgconf:
@@ -187,12 +244,14 @@ class dispatch:
             else:
                 return True
 
-        confs = filter (f, confs)
+        confs = [x for x in confs if f(x)]
 
         #
         # Interactively process remaining
         #
 
+        valid_input = "qhtnmlezu"
+
         for conf in confs:
             count = count + 1
 
@@ -203,17 +262,33 @@ class dispatch:
             show_new_diff = 0
 
             while 1:
+                clear_screen()
                 if show_new_diff:
-                    os.system((self.options['diff']) % (conf['new'], mrgconf))
+                    cmd = self.options['diff'] % (conf['new'], mrgconf)
+                    cmd += pager
+                    spawn_shell(cmd)
                     show_new_diff = 0
                 else:
-                    os.system((self.options['diff']) % (conf['current'], newconf))
-
-                print
-                print '>> (%i of %i) -- %s' % (count, len(confs), conf ['current'])
-                print '>> q quit, h help, n next, e edit-new, z zap-new, u use-new\n   m merge, t toggle-merge, l look-merge: ',
-
-                c = getch ()
+                    cmd = self.options['diff'] % (conf['current'], newconf)
+                    cmd += pager
+                    spawn_shell(cmd)
+
+                print()
+                print('>> (%i of %i) -- %s' % (count, len(confs), conf ['current']))
+                print('>> q quit, h help, n next, e edit-new, z zap-new, u use-new\n   m merge, t toggle-merge, l look-merge: ', end=' ')
+
+                # In some cases getch() will return some spurious characters
+                # that do not represent valid input. If we don't validate the
+                # input then the spurious characters can cause us to jump
+                # back into the above "diff" command immediatly after the user
+                # has exited it (which can be quite confusing and gives an
+                # "out of control" feeling).
+                while True:
+                    c = getch()
+                    if c in valid_input:
+                        sys.stdout.write('\n')
+                        sys.stdout.flush()
+                        break
 
                 if c == 'q':
                     sys.exit (0)
@@ -230,10 +305,13 @@ class dispatch:
                     break
                 elif c == 'm':
                     merged = SCRATCH_DIR+"/"+os.path.basename(conf['current'])
-                    print
+                    print()
                     ret = os.system (self.options['merge'] % (merged, conf ['current'], newconf))
+                    ret = os.WEXITSTATUS(ret)
+                    if ret < 2:
+                        ret = 0
                     if ret:
-                        print "Failure running 'merge' command"
+                        print("Failure running 'merge' command")
                         continue
                     shutil.copyfile(merged, mrgconf)
                     os.remove(merged)
@@ -246,7 +324,7 @@ class dispatch:
                     show_new_diff = 1
                     continue
                 elif c == 'e':
-                    if not os.environ.has_key('EDITOR'):
+                    if 'EDITOR' not in os.environ:
                         os.environ['EDITOR']='nano'
                     os.system(os.environ['EDITOR'] + ' ' + newconf)
                     continue
@@ -264,15 +342,15 @@ class dispatch:
                         os.unlink(mrgconf)
                     break
                 else:
-                    continue
+                    raise AssertionError("Invalid Input: %s" % c)
 
         if auto_zapped:
-            print
-            print " One or more updates are frozen and have been automatically zapped:"
-            print
+            print()
+            print(" One or more updates are frozen and have been automatically zapped:")
+            print()
             for frozen in auto_zapped:
-                print "  * '%s'" % frozen
-            print
+                print("  * '%s'" % frozen)
+            print()
 
     def replace (self, newconf, curconf):
         """Replace current config with the new/merged version.  Also logs
@@ -280,9 +358,9 @@ class dispatch:
         os.system((DIFF_CONTENTS % (curconf, newconf)) + '>>' + self.options["log-file"])
         try:
             os.rename(newconf, curconf)
-        except (IOError, os.error), why:
-            print >> sys.stderr, 'dispatch-conf: Error renaming %s to %s: %s; fatal' % \
-                  (newconf, curconf, str(why))
+        except (IOError, os.error) as why:
+            print('dispatch-conf: Error renaming %s to %s: %s; fatal' % \
+                  (newconf, curconf, str(why)), file=sys.stderr)
 
 
     def post_process(self, curconf):
@@ -302,42 +380,47 @@ class dispatch:
         We keep ._cfg0002_conf over ._cfg0001_conf and ._cfg0000_conf.
         """
         h = {}
-
+        configs = []
         newconfigs.sort ()
 
         for nconf in newconfigs:
             nconf = nconf.rstrip ()
             conf  = re.sub (r'\._cfg\d+_', '', nconf)
-            dir   = re.match (r'^(.+)/', nconf).group (1)
-
-            if h.has_key (conf):
+            dirname   = os.path.dirname(nconf)
+            conf_map  = {
+                'current' : conf,
+                'dir'     : dirname,
+                'new'     : nconf,
+            }
+
+            if conf in h:
                 mrgconf = re.sub(r'\._cfg', '._mrg', h[conf]['new'])
                 if os.path.exists(mrgconf):
                     os.unlink(mrgconf)
                 os.unlink(h[conf]['new'])
-
-            h [conf] = { 'current' : conf, 'dir' : dir, 'new' : nconf }
-
-        configs = h.values ()
-        configs.sort (lambda a, b: cmp(a ['current'], b ['current']))
+                h[conf].update(conf_map)
+            else:
+                h[conf] = conf_map
+                configs.append(conf_map)
 
         return configs
 
 
     def do_help (self):
-        print; print
+        print()
+        print()
 
-        print '  u -- update current config with new config and continue'
-        print '  z -- zap (delete) new config and continue'
-        print '  n -- skip to next config, leave all intact'
-        print '  e -- edit new config'
-        print '  m -- interactively merge current and new configs'
-        print '  l -- look at diff between pre-merged and merged configs'
-        print '  t -- toggle new config between merged and pre-merged state'
-        print '  h -- this screen'
-        print '  q -- quit'
+        print('  u -- update current config with new config and continue')
+        print('  z -- zap (delete) new config and continue')
+        print('  n -- skip to next config, leave all intact')
+        print('  e -- edit new config')
+        print('  m -- interactively merge current and new configs')
+        print('  l -- look at diff between pre-merged and merged configs')
+        print('  t -- toggle new config between merged and pre-merged state')
+        print('  h -- this screen')
+        print('  q -- quit')
 
-        print; print 'press any key to return to diff...',
+        print(); print('press any key to return to diff...', end=' ')
 
         getch ()
 
@@ -345,7 +428,7 @@ class dispatch:
 def getch ():
     # from ASPN - Danny Yoo
     #
-    import sys, tty, termios
+    import tty, termios
 
     fd = sys.stdin.fileno()
     old_settings = termios.tcgetattr(fd)
@@ -356,12 +439,54 @@ def getch ():
         termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
     return ch
 
+def clear_screen():
+    try:
+        import curses
+        try:
+            curses.setupterm()
+            sys.stdout.write(_unicode_decode(curses.tigetstr("clear")))
+            sys.stdout.flush()
+            return
+        except curses.error:
+            pass
+    except ImportError:
+        pass
+    os.system("clear 2>/dev/null")
+
+shell = os.environ.get("SHELL")
+if not shell or not os.access(shell, os.EX_OK):
+    shell = find_binary("sh")
+
+def spawn_shell(cmd):
+    if shell:
+        sys.__stdout__.flush()
+        sys.__stderr__.flush()
+        spawn([shell, "-c", cmd], env=os.environ,
+            fd_pipes = {  0 : portage._get_stdin().fileno(),
+                          1 : sys.__stdout__.fileno(),
+                          2 : sys.__stderr__.fileno()})
+    else:
+        os.system(cmd)
+
+def usage(argv):
+    print('dispatch-conf: sane configuration file update\n')
+    print('Usage: dispatch-conf [config dirs]\n')
+    print('See the dispatch-conf(1) man page for more details')
+    sys.exit(os.EX_OK)
+
+for x in sys.argv:
+    if x in ('-h', '--help'):
+        usage(sys.argv)
+    elif x in ('--version'):
+        print("Portage", portage.VERSION)
+        sys.exit(os.EX_OK)
 
 # run
 d = dispatch ()
 
 if len(sys.argv) > 1:
     # for testing
-    d.grind (" ".join(sys.argv[1:]))
+    d.grind(sys.argv[1:])
 else:
-    d.grind (portage.settings ['CONFIG_PROTECT'])
+    d.grind(portage.util.shlex_split(
+        portage.settings.get('CONFIG_PROTECT', '')))