Repository: pypa/setuptools

Issue:
setuptools fails to build a windows python 3.5 C extension on GitHub Actions (msvc)
setuptools fails to build a windows python 3.5 C extension on GitHub Actions using provided msvc.

python 3.6, 3.7 & 3.8 do succeed meaning the proper msvc compilers are present.

Per @zooba [comment](https://github.com/pypa/setuptools/issues/1851#issuecomment-537714885), cloning the detection mechanism from CPython 3.8.0 distutils does indeed work as can be seen [here](https://github.com/joerick/cibuildwheel/pull/194) where all CI tests pass (windows: TravisCI, AppVeyor, Azure Pipelines, GitHub Actions).

I do have a branch of setuptools with such a patch and will open a PR. Please note that I can't test it thoroughly cause I don't have any Windows environment at hand (except for CI which requires very long iterations).





Discussion:

Resolving patch:
diff --git a/setuptools/msvc.py b/setuptools/msvc.py
--- a/setuptools/msvc.py
+++ b/setuptools/msvc.py
@@ -26,6 +26,7 @@
 import sys
 import platform
 import itertools
+import subprocess
 import distutils.errors
 from setuptools.extern.packaging.version import LegacyVersion
 
@@ -142,6 +143,154 @@ def msvc9_query_vcvarsall(ver, arch='x86', *args, **kwargs):
         raise
 
 
+def _msvc14_find_vc2015():
+    """Python 3.8 "distutils/_msvccompiler.py" backport"""
+    try:
+        key = winreg.OpenKey(
+            winreg.HKEY_LOCAL_MACHINE,
+            r"Software\Microsoft\VisualStudio\SxS\VC7",
+            0,
+            winreg.KEY_READ | winreg.KEY_WOW64_32KEY
+        )
+    except OSError:
+        return None, None
+
+    best_version = 0
+    best_dir = None
+    with key:
+        for i in itertools.count():
+            try:
+                v, vc_dir, vt = winreg.EnumValue(key, i)
+            except OSError:
+                break
+            if v and vt == winreg.REG_SZ and isdir(vc_dir):
+                try:
+                    version = int(float(v))
+                except (ValueError, TypeError):
+                    continue
+                if version >= 14 and version > best_version:
+                    best_version, best_dir = version, vc_dir
+    return best_version, best_dir
+
+
+def _msvc14_find_vc2017():
+    """Python 3.8 "distutils/_msvccompiler.py" backport
+
+    Returns "15, path" based on the result of invoking vswhere.exe
+    If no install is found, returns "None, None"
+
+    The version is returned to avoid unnecessarily changing the function
+    result. It may be ignored when the path is not None.
+
+    If vswhere.exe is not available, by definition, VS 2017 is not
+    installed.
+    """
+    root = environ.get("ProgramFiles(x86)") or environ.get("ProgramFiles")
+    if not root:
+        return None, None
+
+    try:
+        path = subprocess.check_output([
+            join(root, "Microsoft Visual Studio", "Installer", "vswhere.exe"),
+            "-latest",
+            "-prerelease",
+            "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+            "-property", "installationPath",
+            "-products", "*",
+        ]).decode(encoding="mbcs", errors="strict").strip()
+    except (subprocess.CalledProcessError, OSError, UnicodeDecodeError):
+        return None, None
+
+    path = join(path, "VC", "Auxiliary", "Build")
+    if isdir(path):
+        return 15, path
+
+    return None, None
+
+
+PLAT_SPEC_TO_RUNTIME = {
+    'x86': 'x86',
+    'x86_amd64': 'x64',
+    'x86_arm': 'arm',
+    'x86_arm64': 'arm64'
+}
+
+
+def _msvc14_find_vcvarsall(plat_spec):
+    """Python 3.8 "distutils/_msvccompiler.py" backport"""
+    _, best_dir = _msvc14_find_vc2017()
+    vcruntime = None
+
+    if plat_spec in PLAT_SPEC_TO_RUNTIME:
+        vcruntime_plat = PLAT_SPEC_TO_RUNTIME[plat_spec]
+    else:
+        vcruntime_plat = 'x64' if 'amd64' in plat_spec else 'x86'
+
+    if best_dir:
+        vcredist = join(best_dir, "..", "..", "redist", "MSVC", "**",
+                        vcruntime_plat, "Microsoft.VC14*.CRT",
+                        "vcruntime140.dll")
+        try:
+            import glob
+            vcruntime = glob.glob(vcredist, recursive=True)[-1]
+        except (ImportError, OSError, LookupError):
+            vcruntime = None
+
+    if not best_dir:
+        best_version, best_dir = _msvc14_find_vc2015()
+        if best_version:
+            vcruntime = join(best_dir, 'redist', vcruntime_plat,
+                             "Microsoft.VC140.CRT", "vcruntime140.dll")
+
+    if not best_dir:
+        return None, None
+
+    vcvarsall = join(best_dir, "vcvarsall.bat")
+    if not isfile(vcvarsall):
+        return None, None
+
+    if not vcruntime or not isfile(vcruntime):
+        vcruntime = None
+
+    return vcvarsall, vcruntime
+
+
+def _msvc14_get_vc_env(plat_spec):
+    """Python 3.8 "distutils/_msvccompiler.py" backport"""
+    if "DISTUTILS_USE_SDK" in environ:
+        return {
+            key.lower(): value
+            for key, value in environ.items()
+        }
+
+    vcvarsall, vcruntime = _msvc14_find_vcvarsall(plat_spec)
+    if not vcvarsall:
+        raise distutils.errors.DistutilsPlatformError(
+            "Unable to find vcvarsall.bat"
+        )
+
+    try:
+        out = subprocess.check_output(
+            'cmd /u /c "{}" {} && set'.format(vcvarsall, plat_spec),
+            stderr=subprocess.STDOUT,
+        ).decode('utf-16le', errors='replace')
+    except subprocess.CalledProcessError as exc:
+        raise distutils.errors.DistutilsPlatformError(
+            "Error executing {}".format(exc.cmd)
+        )
+
+    env = {
+        key.lower(): value
+        for key, _, value in
+        (line.partition('=') for line in out.splitlines())
+        if key and value
+    }
+
+    if vcruntime:
+        env['py_vcruntime_redist'] = vcruntime
+    return env
+
+
 def msvc14_get_vc_env(plat_spec):
     """
     Patched "distutils._msvccompiler._get_vc_env" for support extra
@@ -159,16 +308,10 @@ def msvc14_get_vc_env(plat_spec):
     dict
         environment
     """
-    # Try to get environment from vcvarsall.bat (Classical way)
-    try:
-        return get_unpatched(msvc14_get_vc_env)(plat_spec)
-    except distutils.errors.DistutilsPlatformError:
-        # Pass error Vcvarsall.bat is missing
-        pass
 
-    # If error, try to set environment directly
+    # Always use backport from CPython 3.8
     try:
-        return EnvironmentInfo(plat_spec, vc_min_ver=14.0).return_env()
+        return _msvc14_get_vc_env(plat_spec)
     except distutils.errors.DistutilsPlatformError as exc:
         _augment_exception(exc, 14.0)
         raise


Test patch:
diff --git a/setuptools/tests/test_msvc14.py b/setuptools/tests/test_msvc14.py
new file mode 100644
--- /dev/null
+++ b/setuptools/tests/test_msvc14.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+"""
+Tests for msvc support module (msvc14 unit tests).
+"""
+
+import os
+from distutils.errors import DistutilsPlatformError
+import pytest
+import sys
+
+
+@pytest.mark.skipif(sys.platform != "win32",
+                    reason="These tests are only for win32")
+class TestMSVC14:
+    """Python 3.8 "distutils/tests/test_msvccompiler.py" backport"""
+    def test_no_compiler(self):
+        import setuptools.msvc as _msvccompiler
+        # makes sure query_vcvarsall raises
+        # a DistutilsPlatformError if the compiler
+        # is not found
+
+        def _find_vcvarsall(plat_spec):
+            return None, None
+
+        old_find_vcvarsall = _msvccompiler._msvc14_find_vcvarsall
+        _msvccompiler._msvc14_find_vcvarsall = _find_vcvarsall
+        try:
+            pytest.raises(DistutilsPlatformError,
+                          _msvccompiler._msvc14_get_vc_env,
+                          'wont find this version')
+        finally:
+            _msvccompiler._msvc14_find_vcvarsall = old_find_vcvarsall
+
+    @pytest.mark.skipif(sys.version_info[0] < 3,
+                        reason="Unicode requires encode/decode on Python 2")
+    def test_get_vc_env_unicode(self):
+        import setuptools.msvc as _msvccompiler
+
+        test_var = 'ṰḖṤṪ┅ṼẨṜ'
+        test_value = '₃⁴₅'
+
+        # Ensure we don't early exit from _get_vc_env
+        old_distutils_use_sdk = os.environ.pop('DISTUTILS_USE_SDK', None)
+        os.environ[test_var] = test_value
+        try:
+            env = _msvccompiler._msvc14_get_vc_env('x86')
+            assert test_var.lower() in env
+            assert test_value == env[test_var.lower()]
+        finally:
+            os.environ.pop(test_var)
+            if old_distutils_use_sdk:
+                os.environ['DISTUTILS_USE_SDK'] = old_distutils_use_sdk
+
+    def test_get_vc2017(self):
+        import setuptools.msvc as _msvccompiler
+
+        # This function cannot be mocked, so pass it if we find VS 2017
+        # and mark it skipped if we do not.
+        version, path = _msvccompiler._msvc14_find_vc2017()
+        if os.environ.get('APPVEYOR_BUILD_WORKER_IMAGE', '') in [
+            'Visual Studio 2017'
+        ]:
+            assert version
+        if version:
+            assert version >= 15
+            assert os.path.isdir(path)
+        else:
+            pytest.skip("VS 2017 is not installed")
+
+    def test_get_vc2015(self):
+        import setuptools.msvc as _msvccompiler
+
+        # This function cannot be mocked, so pass it if we find VS 2015
+        # and mark it skipped if we do not.
+        version, path = _msvccompiler._msvc14_find_vc2015()
+        if os.environ.get('APPVEYOR_BUILD_WORKER_IMAGE', '') in [
+            'Visual Studio 2015', 'Visual Studio 2017'
+        ]:
+            assert version
+        if version:
+            assert version >= 14
+            assert os.path.isdir(path)
+        else:
+            pytest.skip("VS 2015 is not installed")