Repository: pypa/setuptools
Pull request #718 (2016-08-04 02:00:41)

use abi3 extension if Extension().is_abi3
Add an Extension(is_abi3=True) property that can be used to set its filename to something that will be loadable by more than one version of Python 3. An '.abi3.so' extension on Linux, just .pyd on Windows. Tested on Linux so far.

It will still be necessary to `-DPy_LIMITED_API=0x03030000` somewhere for the extension to truly be abi3.


Patch:
diff --git a/.hgtags b/.hgtags
index bff7e77e4..1b8331855 100644
--- a/.hgtags
+++ b/.hgtags
@@ -295,7 +295,5 @@ dc92db3e29a4d1ac57d383091e6cf734d04ed54b v25.0.2
 529a76a860c50d3cc262759b5b9ce28f171236f9 v25.1.0
 392ee093902e14a1d2a6eefc389a7b9ac78b3f9e v25.1.1
 1cbb29c235439331a76c7b6b5cf8701f763478d3 v25.1.2
-c350190e7bbf274e6728f14af7451b1fd3aaeba2 v25.1.2
+c350190e7bbf274e6728f14af7451b1fd3aaeba2 25.1.2
 86e668badaf45315bb8506ac2312665d129a0322 v25.1.3
-6f55250b9c5856557ac669d1f966bba8be9eb1d2 v25.1.4
-76143bb477b50314ab6f4ccc4ced80ee43f0dc94 v25.1.5
diff --git a/CHANGES.rst b/CHANGES.rst
index 07f0d191d..3c4b118e7 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,25 +2,20 @@
 CHANGES
 =======
 
-v25.1.5
--------
-
-* #720
-* #723
-
-v25.1.4
--------
-
-* #717
-* #713
-* #707 via #715
-
 v25.1.3
 -------
 
 * #714 and #704: Revert fix as it breaks other components
   downstream that can't handle unicode. See #709, #710,
   and #712.
+* Add Extension(py_limited_api=True). When set to a truthy value,
+  that extension gets a filename apropriate for code using Py_LIMITED_API.
+  When used correctly this allows a single compiled extension to work on
+  all future versions of CPython 3.
+  The py_limited_api argument only controls the filename. To be
+  compatible with multiple versions of Python 3, the C extension
+  will also need to set -DPy_LIMITED_API=... and be modified to use
+  only the functions in the limited API.
 
 v25.1.2
 -------
diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py
index 2a053b509..9f0ea6ec5 100644
--- a/pkg_resources/__init__.py
+++ b/pkg_resources/__init__.py
@@ -1468,11 +1468,16 @@ class NullProvider:
     def has_metadata(self, name):
         return self.egg_info and self._has(self._fn(self.egg_info, name))
 
-    def get_metadata(self, name):
-        if not self.egg_info:
-            return ""
-        value = self._get(self._fn(self.egg_info, name))
-        return value.decode('utf-8') if six.PY3 else value
+    if sys.version_info <= (3,):
+        def get_metadata(self, name):
+            if not self.egg_info:
+                return ""
+            return self._get(self._fn(self.egg_info, name))
+    else:
+        def get_metadata(self, name):
+            if not self.egg_info:
+                return ""
+            return self._get(self._fn(self.egg_info, name)).decode("utf-8")
 
     def get_metadata_lines(self, name):
         return yield_lines(self.get_metadata(name))
diff --git a/setup.cfg b/setup.cfg
index 3fad1dbca..4f08b809b 100755
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 25.1.5
+current_version = 25.1.3
 commit = True
 tag = True
 
diff --git a/setup.py b/setup.py
index 67891be2e..2309b7d2e 100755
--- a/setup.py
+++ b/setup.py
@@ -88,7 +88,7 @@ def pypi_link(pkg_filename):
 
 setup_params = dict(
     name="setuptools",
-    version="25.1.5",
+    version="25.1.3",
     description="Easily download, build, install, upgrade, and uninstall "
         "Python packages",
     author="Python Packaging Authority",
@@ -167,11 +167,11 @@ setup_params = dict(
         """).strip().splitlines(),
     extras_require={
         "ssl:sys_platform=='win32'": "wincertstore==0.2",
-        "certs": "certifi==2016.8.2",
+        "certs": "certifi==2016.2.28",
     },
     dependency_links=[
         pypi_link(
-            'certifi-2016.8.2.tar.gz#md5=004ae166985d3a684bcac5368e22ed63',
+            'certifi-2016.2.28.tar.gz#md5=5d672aa766e1f773c75cfeccd02d3650',
         ),
         pypi_link(
             'wincertstore-0.2.zip#md5=ae728f2f007185648d0c7a8679b361e2',
diff --git a/setuptools/command/bdist_egg.py b/setuptools/command/bdist_egg.py
index cbea7537e..50744b87d 100644
--- a/setuptools/command/bdist_egg.py
+++ b/setuptools/command/bdist_egg.py
@@ -129,7 +129,7 @@ class bdist_egg(Command):
             self.distribution.data_files.append(item)
 
         try:
-            log.info("installing package data to %s", self.bdist_dir)
+            log.info("installing package data to %s" % self.bdist_dir)
             self.call_command('install_data', force=0, root=None)
         finally:
             self.distribution.data_files = old
@@ -152,7 +152,7 @@ class bdist_egg(Command):
         self.run_command("egg_info")
         # We run install_lib before install_data, because some data hacks
         # pull their data path from the install_lib command.
-        log.info("installing library code to %s", self.bdist_dir)
+        log.info("installing library code to %s" % self.bdist_dir)
         instcmd = self.get_finalized_command('install')
         old_root = instcmd.root
         instcmd.root = None
@@ -169,7 +169,7 @@ class bdist_egg(Command):
             pyfile = os.path.join(self.bdist_dir, strip_module(filename) +
                                   '.py')
             self.stubs.append(pyfile)
-            log.info("creating stub loader for %s", ext_name)
+            log.info("creating stub loader for %s" % ext_name)
             if not self.dry_run:
                 write_stub(os.path.basename(ext_name), pyfile)
             to_compile.append(pyfile)
@@ -186,14 +186,14 @@ class bdist_egg(Command):
         self.mkpath(egg_info)
         if self.distribution.scripts:
             script_dir = os.path.join(egg_info, 'scripts')
-            log.info("installing scripts to %s", script_dir)
+            log.info("installing scripts to %s" % script_dir)
             self.call_command('install_scripts', install_dir=script_dir,
                               no_ep=1)
 
         self.copy_metadata_to(egg_info)
         native_libs = os.path.join(egg_info, "native_libs.txt")
         if all_outputs:
-            log.info("writing %s", native_libs)
+            log.info("writing %s" % native_libs)
             if not self.dry_run:
                 ensure_directory(native_libs)
                 libs_file = open(native_libs, 'wt')
@@ -201,7 +201,7 @@ class bdist_egg(Command):
                 libs_file.write('\n')
                 libs_file.close()
         elif os.path.isfile(native_libs):
-            log.info("removing %s", native_libs)
+            log.info("removing %s" % native_libs)
             if not self.dry_run:
                 os.unlink(native_libs)
 
@@ -458,7 +458,7 @@ def make_zipfile(zip_filename, base_dir, verbose=0, dry_run=0, compress=True,
                 p = path[len(base_dir) + 1:]
                 if not dry_run:
                     z.write(path, p)
-                log.debug("adding '%s'", p)
+                log.debug("adding '%s'" % p)
 
     compression = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
     if not dry_run:
diff --git a/setuptools/command/build_ext.py b/setuptools/command/build_ext.py
index e6db0764e..7bb4d24c8 100644
--- a/setuptools/command/build_ext.py
+++ b/setuptools/command/build_ext.py
@@ -59,6 +59,14 @@ elif os.name != 'nt':
 
 if_dl = lambda s: s if have_rtld else ''
 
+def get_abi3_suffix():
+    """Return the file extension for an abi3-compliant Extension()"""
+    import imp
+    for suffix, _, _ in (s for s in imp.get_suffixes() if s[2] == imp.C_EXTENSION):
+        if '.abi3' in suffix:   # Unix
+            return suffix
+        elif suffix == '.pyd':  # Windows
+            return suffix
 
 class build_ext(_build_ext):
 
@@ -96,6 +104,13 @@ class build_ext(_build_ext):
         filename = _build_ext.get_ext_filename(self, fullname)
         if fullname in self.ext_map:
             ext = self.ext_map[fullname]
+            if (sys.version_info[0] != 2 
+                and getattr(ext, 'py_limited_api')
+                and get_abi3_suffix()):
+                from distutils.sysconfig import get_config_var
+                so_ext = get_config_var('SO')
+                filename = filename[:-len(so_ext)]
+                filename = filename + get_abi3_suffix()
             if isinstance(ext, Library):
                 fn, ext = os.path.splitext(filename)
                 return self.shlib_compiler.library_filename(fn, libtype)
diff --git a/setuptools/extension.py b/setuptools/extension.py
index 6b9c19f8b..c8e9bc7db 100644
--- a/setuptools/extension.py
+++ b/setuptools/extension.py
@@ -19,7 +19,7 @@ def _have_cython():
     """
     Return True if Cython can be imported.
     """
-    cython_impl = 'Cython.Distutils.build_ext'
+    cython_impl = 'Cython.Distutils.build_ext',
     try:
         # from (cython_impl) import build_ext
         __import__(cython_impl, fromlist=['build_ext']).build_ext
@@ -36,6 +36,10 @@ have_pyrex = _have_cython
 class Extension(_Extension):
     """Extension that uses '.c' files in place of '.pyx' files"""
 
+    def __init__(self, name, sources, py_limited_api=False, **kw):
+        self.py_limited_api = py_limited_api
+        _Extension.__init__(self, name, sources, **kw)
+
     def _convert_pyx_sources_to_lang(self):
         """
         Replace sources with .pyx extensions to sources with the target
diff --git a/setuptools/msvc.py b/setuptools/msvc.py
index 97dd441c5..6fb3300b0 100644
--- a/setuptools/msvc.py
+++ b/setuptools/msvc.py
@@ -85,13 +85,6 @@ def patch_for_specialized_compiler():
     except Exception:
         pass
 
-    try:
-        # Patch distutils._msvccompiler.MSVCCompiler.library_dir_option
-        unpatched['msvc14_library_dir_option'] = msvc14compiler.MSVCCompiler.library_dir_option
-        msvc14compiler.MSVCCompiler.library_dir_option = msvc14_library_dir_option
-    except Exception:
-        pass
-
 
 def msvc9_find_vcvarsall(version):
     """
@@ -219,27 +212,6 @@ def msvc14_get_vc_env(plat_spec):
         raise
 
 
-def msvc14_library_dir_option(self, dir):
-    """
-    Patched "distutils._msvccompiler.MSVCCompiler.library_dir_option"
-    to fix unquoted path in "\LIBPATH" argument when a space is on path.
-
-    Parameters
-    ----------
-    dir: str
-        Path to convert in "\LIBPATH" argument.
-
-    Return
-    ------
-    "\LIBPATH" argument: str
-    """
-    opt = unpatched['msvc14_library_dir_option'](self, dir)
-    if ' ' in opt and '"' not in opt:
-        # Quote if space and not already quoted
-        opt = '"%s"' % opt
-    return opt
-
-
 def _augment_exception(exc, version, arch=''):
     """
     Add details to the exception message to help guide the user
@@ -384,12 +356,23 @@ class RegistryInfo:
     def __init__(self, platform_info):
         self.pi = platform_info
 
+    @property
+    def microsoft(self):
+        """
+        Microsoft software registry key.
+        """
+        return os.path.join(
+            'Software',
+            '' if self.pi.current_is_x86() else 'Wow6432Node',
+            'Microsoft',
+        )
+
     @property
     def visualstudio(self):
         """
         Microsoft Visual Studio root registry key.
         """
-        return 'VisualStudio'
+        return os.path.join(self.microsoft, 'VisualStudio')
 
     @property
     def sxs(self):
@@ -417,14 +400,15 @@ class RegistryInfo:
         """
         Microsoft Visual C++ for Python registry key.
         """
-        return r'DevDiv\VCForPython'
+        path = r'DevDiv\VCForPython'
+        return os.path.join(self.microsoft, path)
 
     @property
     def microsoft_sdk(self):
         """
         Microsoft SDK registry key.
         """
-        return 'Microsoft SDKs'
+        return os.path.join(self.microsoft, 'Microsoft SDKs')
 
     @property
     def windows_sdk(self):
@@ -445,29 +429,11 @@ class RegistryInfo:
         """
         Microsoft Windows Kits Roots registry key.
         """
-        return r'Windows Kits\Installed Roots'
-
-    def microsoft(self, key, x86=False):
-        """
-        Return key in Microsoft software registry.
-
-        Parameters
-        ----------
-        key: str
-            Registry key path where look.
-        x86: str
-            Force x86 software registry.
-
-        Return
-        ------
-        str: value
-        """
-        node64 = '' if self.pi.current_is_x86() or x86 else r'\Wow6432Node'
-        return os.path.join('Software', node64, 'Microsoft', key)
+        return os.path.join(self.microsoft, r'Windows Kits\Installed Roots')
 
     def lookup(self, key, name):
         """
-        Look for values in registry in Microsoft software registry.
+        Look for values in registry.
 
         Parameters
         ----------
@@ -480,23 +446,18 @@ class RegistryInfo:
         ------
         str: value
         """
-        KEY_READ = winreg.KEY_READ
-        openkey = winreg.OpenKey
-        ms = self.microsoft
         for hkey in self.HKEYS:
             try:
-                bkey = openkey(hkey, ms(key), 0, KEY_READ)
-            except (OSError, IOError):
-                if not self.pi.current_is_x86():
-                    try:
-                        bkey = openkey(hkey, ms(key, True), 0, KEY_READ)
-                    except (OSError, IOError):
-                        continue
-                else:
-                    continue
+                bkey = winreg.OpenKey(hkey, key, 0, winreg.KEY_READ)
+            except OSError:
+                continue
+            except IOError:
+                continue
             try:
                 return winreg.QueryValueEx(bkey, name)[0]
-            except (OSError, IOError):
+            except OSError:
+                pass
+            except IOError:
                 pass
 
 
@@ -539,7 +500,7 @@ class SystemInfo:
             for key in vckeys:
                 try:
                     bkey = winreg.OpenKey(hkey, key, 0, winreg.KEY_READ)
-                except (OSError, IOError):
+                except IOError:
                     continue
                 subkeys, values, _ = winreg.QueryInfoKey(bkey)
                 for i in range(values):
@@ -852,7 +813,7 @@ class EnvironmentInfo:
         Microsoft Visual C++ & Microsoft Foundation Class Includes
         """
         return [os.path.join(self.si.VCInstallDir, 'Include'),
-                os.path.join(self.si.VCInstallDir, r'ATLMFC\Include')]
+                os.path.join(self.si.VCInstallDir, 'ATLMFC\Include')]
 
     @property
     def VCLibraries(self):
@@ -1238,5 +1199,5 @@ class EnvironmentInfo:
             if name:
                 return '%s\\' % name[0]
             return ''
-        except (OSError, IOError):
+        except IOError:
             return ''
diff --git a/setuptools/package_index.py b/setuptools/package_index.py
index 82cd608f5..8d965f490 100755
--- a/setuptools/package_index.py
+++ b/setuptools/package_index.py
@@ -290,12 +290,7 @@ class PackageIndex(Environment):
         self.package_pages = {}
         self.allows = re.compile('|'.join(map(translate, hosts))).match
         self.to_scan = []
-        use_ssl = (
-            verify_ssl
-            and ssl_support.is_available
-            and (ca_bundle or ssl_support.find_ca_bundle())
-        )
-        if use_ssl:
+        if verify_ssl and ssl_support.is_available and (ca_bundle or ssl_support.find_ca_bundle()):
             self.opener = ssl_support.opener_for(ca_bundle)
         else:
             self.opener = urllib.request.urlopen
@@ -325,8 +320,7 @@ class PackageIndex(Environment):
 
         self.info("Reading %s", url)
         self.fetched_urls[url] = True   # prevent multiple fetch attempts
-        tmpl = "Download error on %s: %%s -- Some packages may not be found!"
-        f = self.open_url(url, tmpl % url)
+        f = self.open_url(url, "Download error on %s: %%s -- Some packages may not be found!" % url)
         if f is None:
             return
         self.fetched_urls[f.url] = True
@@ -368,8 +362,7 @@ class PackageIndex(Environment):
 
     def url_ok(self, url, fatal=False):
         s = URL_SCHEME(url)
-        is_file = s and s.group(1).lower() == 'file'
-        if is_file or self.allows(urllib.parse.urlparse(url)[1]):
+        if (s and s.group(1).lower() == 'file') or self.allows(urllib.parse.urlparse(url)[1]):
             return True
         msg = ("\nNote: Bypassing %s (disallowed host; see "
             "http://bit.ly/1dg9ijs for details).\n")
@@ -1046,7 +1039,7 @@ def open_with_auth(url, opener=urllib.request.urlopen):
         if cred:
             auth = str(cred)
             info = cred.username, url
-            log.info('Authenticating as %s for %s (from .pypirc)', *info)
+            log.info('Authenticating as %s for %s (from .pypirc)' % info)
 
     if auth:
         auth = "Basic " + _encode_auth(auth)


Test patch:
diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py
index 5168ebf05..f2e1f59db 100644
--- a/setuptools/tests/test_build_ext.py
+++ b/setuptools/tests/test_build_ext.py
@@ -1,8 +1,10 @@
+import sys
 import distutils.command.build_ext as orig
 
-from setuptools.command.build_ext import build_ext
+from distutils.sysconfig import get_config_var
+from setuptools.command.build_ext import build_ext, get_abi3_suffix
 from setuptools.dist import Distribution
-
+from setuptools.extension import Extension
 
 class TestBuildExt:
 
@@ -18,3 +20,24 @@ class TestBuildExt:
         res = cmd.get_ext_filename('foo')
         wanted = orig.build_ext.get_ext_filename(cmd, 'foo')
         assert res == wanted
+
+    def test_abi3_filename(self):
+        """
+        Filename needs to be loadable by several versions
+        of Python 3 if 'is_abi3' is truthy on Extension()
+        """
+        print(get_abi3_suffix())
+        
+        extension = Extension('spam.eggs', ['eggs.c'], py_limited_api=True)
+        dist = Distribution(dict(ext_modules=[extension]))
+        cmd = build_ext(dist)
+        cmd.finalize_options()
+        assert 'spam.eggs' in cmd.ext_map
+        res = cmd.get_ext_filename('spam.eggs')
+
+        if sys.version_info[0] == 2 or not get_abi3_suffix():
+            assert res.endswith(get_config_var('SO'))
+        elif sys.platform == 'win32':
+            assert res.endswith('eggs.pyd')
+        else:
+            assert 'abi3' in res
\ No newline at end of file
diff --git a/setuptools/tests/test_msvc.py b/setuptools/tests/test_msvc.py
index 14e0f208a..8c7e17d3c 100644
--- a/setuptools/tests/test_msvc.py
+++ b/setuptools/tests/test_msvc.py
@@ -73,6 +73,8 @@ class TestModulePatch:
         mod_name = distutils.msvc9compiler.find_vcvarsall.__module__
         assert mod_name == "setuptools.msvc", "find_vcvarsall unpatched"
 
+    @pytest.mark.xfail(six.PY2,
+        reason="https://github.com/pypa/setuptools/issues/707")
     def test_no_registry_entries_means_nothing_found(self):
         """
         No registry entries or environment variable should lead to an error