repo: try to reexec self with Python 3 as needed

We want to start warning about Python 2 usage, but we can't do it
simply because the shebang is /usr/bin/python which might be an old
version like python2.7.

We can't change the shebang because program name usage is spotty at
best: on some platforms (like macOS), it's not uncommon to not have
a `python3` wrapper, only a major.minor one like `python3.6`.  Using
python3 wouldn't guarantee a new enough version of Python 3 anyways,
and we don't want to require Python 3.6 exactly, just that minimum.

So we check the current Python version.  If it's older than the ver
of Python 3 we want, we search for a `python3.X` version to run.  If
those don't work, we see if `python3` exists and is a new enough ver.
If it's not, we die if the current Python 3 is too old, and we start
issuing warnings if the current Python version is 2.7.  This should
allow the user to take a bit more action by installing Python 3 on
their system without having to worry about changing /usr/bin/python.

Once we require Python 3 completely, we can simplify this logic a bit
by always bootstrapping up to Python 3 and failing with Python 2.

We have a few KI with Windows atm though, so keep it disabled there
until the fixes are merged.

Bug: https://crbug.com/gerrit/10418
Change-Id: I5e157defc788e31efb3e21e93f53fabdc7d75a3c
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/253136
Tested-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
diff --git a/repo b/repo
index 0b87073..95a212d 100755
--- a/repo
+++ b/repo
@@ -10,6 +10,84 @@
 
 from __future__ import print_function
 
+import os
+import platform
+import subprocess
+import sys
+
+
+def exec_command(cmd):
+  """Execute |cmd| or return None on failure."""
+  try:
+    if platform.system() == 'Windows':
+      ret = subprocess.call(cmd)
+      sys.exit(ret)
+    else:
+      os.execvp(cmd[0], cmd)
+  except:
+    pass
+
+
+def check_python_version():
+  """Make sure the active Python version is recent enough."""
+  def reexec(prog):
+    exec_command([prog] + sys.argv)
+
+  MIN_PYTHON_VERSION = (3, 6)
+
+  ver = sys.version_info
+  major = ver.major
+  minor = ver.minor
+
+  # Abort on very old Python 2 versions.
+  if (major, minor) < (2, 7):
+    print('repo: error: Your Python version is too old. '
+          'Please use Python {}.{} or newer instead.'.format(
+              *MIN_PYTHON_VERSION), file=sys.stderr)
+    sys.exit(1)
+
+  # Try to re-exec the version specific Python 3 if needed.
+  if (major, minor) < MIN_PYTHON_VERSION:
+    # Python makes releases ~once a year, so try our min version +10 to help
+    # bridge the gap.  This is the fallback anyways so perf isn't critical.
+    min_major, min_minor = MIN_PYTHON_VERSION
+    for inc in range(0, 10):
+      reexec('python{}.{}'.format(min_major, min_minor + inc))
+
+    # Try the generic Python 3 wrapper, but only if it's new enough.  We don't
+    # want to go from (still supported) Python 2.7 to (unsupported) Python 3.5.
+    try:
+      proc = subprocess.Popen(
+          ['python3', '-c', 'import sys; '
+           'print(sys.version_info.major, sys.version_info.minor)'],
+          stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+      (output, _) = proc.communicate()
+      python3_ver = tuple(int(x) for x in output.decode('utf-8').split())
+    except (OSError, subprocess.CalledProcessError):
+      python3_ver = None
+
+    # The python3 version looks like it's new enough, so give it a try.
+    if python3_ver and python3_ver >= MIN_PYTHON_VERSION:
+      reexec('python3')
+
+    # We're still here, so diagnose things for the user.
+    if major < 3:
+      print('repo: warning: Python 2 is no longer supported; '
+            'Please upgrade to Python {}.{}+.'.format(*MIN_PYTHON_VERSION),
+            file=sys.stderr)
+    else:
+      print('repo: error: Python 3 version is too old; '
+            'Please use Python {}.{} or newer.'.format(*MIN_PYTHON_VERSION),
+            file=sys.stderr)
+      sys.exit(1)
+
+
+if __name__ == '__main__':
+  # TODO(vapier): Enable this on Windows once we have Python 3 issues fixed.
+  if platform.system() != 'Windows':
+    check_python_version()
+
+
 # repo default configuration
 #
 import os
@@ -91,7 +169,6 @@
 S_repo = 'repo'                  # special repo repository
 S_manifests = 'manifests'        # special manifest repository
 REPO_MAIN = S_repo + '/main.py'  # main script
-MIN_PYTHON_VERSION = (2, 7)      # minimum supported python version
 GITC_CONFIG_FILE = '/gitc/.config'
 GITC_FS_ROOT_DIR = '/gitc/manifest-rw/'
 
@@ -99,12 +176,9 @@
 import collections
 import errno
 import optparse
-import platform
 import re
 import shutil
 import stat
-import subprocess
-import sys
 
 if sys.version_info[0] == 3:
   import urllib.request
@@ -117,17 +191,6 @@
   urllib.error = urllib2
 
 
-# Python version check
-ver = sys.version_info
-if (ver[0], ver[1]) < MIN_PYTHON_VERSION:
-  print('error: Python version {} unsupported.\n'
-        'Please use Python {}.{} instead.'.format(
-            sys.version.split(' ')[0],
-            MIN_PYTHON_VERSION[0],
-            MIN_PYTHON_VERSION[1],
-        ), file=sys.stderr)
-  sys.exit(1)
-
 home_dot_repo = os.path.expanduser('~/.repoconfig')
 gpg_dir = os.path.join(home_dot_repo, 'gnupg')
 
@@ -894,15 +957,9 @@
         '--']
   me.extend(orig_args)
   me.extend(extra_args)
-  try:
-    if platform.system() == "Windows":
-      sys.exit(subprocess.call(me))
-    else:
-      os.execv(sys.executable, me)
-  except OSError as e:
-    print("fatal: unable to start %s" % repo_main, file=sys.stderr)
-    print("fatal: %s" % e, file=sys.stderr)
-    sys.exit(148)
+  exec_command(me)
+  print("fatal: unable to start %s" % repo_main, file=sys.stderr)
+  sys.exit(148)
 
 
 if __name__ == '__main__':