Browse Source

Merge pull request #227 from takluyver/wheel-tags-like-packaging

Handle wheel tag compatibility like packaging.tags
Thomas Kluyver 4 years ago
parent
commit
863f0cce0e
7 changed files with 115 additions and 51 deletions
  1. 1 1
      .github/workflows/test.yml
  2. 5 4
      README.rst
  3. 0 1
      appveyor.yml
  4. 16 9
      nsist/tests/test_pypi.py
  5. 92 34
      nsist/wheels.py
  6. 1 1
      pyproject.toml
  7. 0 1
      tox.ini

+ 1 - 1
.github/workflows/test.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [ 3.5, 3.6, 3.7, 3.8, 3.9, ]
+        python-version: [ 3.6, 3.7, 3.8, 3.9, ]
     steps:
       - uses: actions/checkout@v2
 

+ 5 - 4
README.rst

@@ -2,13 +2,14 @@ Pynsist is a tool to build Windows installers for your Python applications. The
 installers bundle Python itself, so you can distribute your application to
 people who don't have Python installed.
 
-Pynsist 2 requires Python 3.5 or above.
-You can use `Pynsist 1.x <http://pynsist.readthedocs.io/en/1.12/>`_ on
-Python 2.7 and Python 3.3 or above.
-
 For more information, see `the documentation <https://pynsist.readthedocs.io/en/latest/>`_
 and `the examples <https://github.com/takluyver/pynsist/tree/master/examples>`_.
 
+Pynsist 2.7 requires Python 3.6 or above.
+You can use `Pynsist 2.6 <http://pynsist.readthedocs.io/en/2.6/>`_ on Python 3.5,
+and `Pynsist 1.x <http://pynsist.readthedocs.io/en/1.12/>`_ on Python 2.7 and
+Python 3.3 or above, but these versions won't get further updates.
+
 Quickstart
 ----------
 

+ 0 - 1
appveyor.yml

@@ -5,7 +5,6 @@ environment:
     - PYTHON: "C:\\Python38"
     - PYTHON: "C:\\Python37-x64"
     - PYTHON: "C:\\Python36"
-    - PYTHON: "C:\\Python35-x64"
 
 install:
   - cinst nsis

+ 16 - 9
nsist/tests/test_pypi.py

@@ -72,8 +72,8 @@ def test_pick_best_wheel():
 
     # Prefer more specific Python version
     releases = [
-        CachedRelease('astsearch-0.1.2-cp37-none-any.whl'),
-        CachedRelease('astsearch-0.1.2-py3-none-any.whl'),
+        CachedRelease('astsearch-0.1.2-cp37-none-win_amd64.whl'),
+        CachedRelease('astsearch-0.1.2-py3-none-win_amd64.whl'),
     ]
     assert wd37.pick_best_wheel(releases) == releases[0]
 
@@ -93,22 +93,22 @@ def test_pick_best_wheel():
 
     # Prefer more specific ABI version
     releases = [
-        CachedRelease('astsearch-0.1.2-py3-abi3-any.whl'),
-        CachedRelease('astsearch-0.1.2-py3-none-any.whl'),
+        CachedRelease('astsearch-0.1.2-cp37-abi3-win_amd64.whl'),
+        CachedRelease('astsearch-0.1.2-cp37-none-win_amd64.whl'),
     ]
     assert wd37.pick_best_wheel(releases) == releases[0]
 
     # ABI suffix on Python <3.8
     releases = [
-        CachedRelease('astsearch-0.1.2-cp37-cp37-any.whl'),
-        CachedRelease('astsearch-0.1.2-cp37-cp37m-any.whl'),
+        CachedRelease('astsearch-0.1.2-cp37-cp37-win_amd64.whl'),
+        CachedRelease('astsearch-0.1.2-cp37-cp37m-win_amd64.whl'),
     ]
     assert wd37.pick_best_wheel(releases) == releases[1]
 
     # No ABI suffix on Python >=3.8
     releases = [
-        CachedRelease('astsearch-0.1.2-cp38-cp38-any.whl'),
-        CachedRelease('astsearch-0.1.2-cp38-cp38m-any.whl'),
+        CachedRelease('astsearch-0.1.2-cp38-cp38-win_amd64.whl'),
+        CachedRelease('astsearch-0.1.2-cp38-cp38m-win_amd64.whl'),
     ]
     assert wd38.pick_best_wheel(releases) == releases[0]
 
@@ -121,11 +121,18 @@ def test_pick_best_wheel():
 
     # Platform has priority over other attributes
     releases = [
-        CachedRelease('astsearch-0.1.2-cp37-abi3-any.whl'),
+        CachedRelease('astsearch-0.1.2-py37-none-any.whl'),
         CachedRelease('astsearch-0.1.2-py2.py3-none-win_amd64.whl'),
     ]
     assert wd37.pick_best_wheel(releases) == releases[1]
 
+    # Older cp3x tags are compatible when used with abi3
+    releases = [
+        CachedRelease('cryptography-3.4.7-cp36-cp36m-win_amd64.whl'),
+        CachedRelease('cryptography-3.4.7-cp36-abi3-win_amd64.whl'),
+    ]
+    assert wd38.pick_best_wheel(releases) == releases[1]
+
 def test_merge_dir_to(tmpdir):
     td1 = Path(str(tmpdir.mkdir('one')))
     td2 = Path(str(tmpdir.mkdir('two')))

+ 92 - 34
nsist/wheels.py

@@ -1,6 +1,7 @@
 """Find, download and unpack wheels."""
 import fnmatch
 import hashlib
+import itertools
 import logging
 import glob
 import os
@@ -27,44 +28,33 @@ class CompatibilityScorer:
     """
     def __init__(self, py_version, platform):
         self.py_version = py_version
-        self.py_version_tuple = tuple(map(int, py_version.split('.')[:2]))
+        py_version_tuple = tuple(map(int, py_version.split('.')[:2]))
         self.platform = platform
+        # {('cp38', 'none', 'any'): N}  (higher N for more specific tags)
+        self.tag_prio = {
+            tag: i for i, tag in enumerate(reversed(
+                list(compatible_tags(py_version_tuple, platform))
+            ), start=1)
+        }
 
-    def score_platform(self, platform):
-        # target = 'win_amd64' if self.bitness == 64 else 'win32'
-        d = {self.platform: 2, 'any': 1}
-        return max(d.get(p, 0) for p in platform.split('.'))
-
-    def score_abi(self, abi):
-        py_version_nodot = '%s%s' % (self.py_version_tuple[0], self.py_version_tuple[1])
-        abi_suffix = 'm' if self.py_version_tuple < (3, 8) else ''
-        # Are there other valid options here?
-        d = {'cp%s%s' % (py_version_nodot, abi_suffix): 3,
-             'abi3': 2,
-             'none': 1}
-        return max(d.get(a, 0) for a in abi.split('.'))
-
-    def score_interpreter(self, interpreter):
-        py_version_nodot = '%s%s' % (self.py_version_tuple[0], self.py_version_tuple[1])
-        py_version_major = str(self.py_version_tuple[0])
-        d = {'cp'+py_version_nodot: 4,
-             'cp'+py_version_major: 3,
-             'py'+py_version_nodot: 2,
-             'py'+py_version_major: 1
-            }
-        return max(d.get(i, 0) for i in interpreter.split('.'))
-
-    def score(self, whl_filename):
+    def score(self, whl_filename: str) -> int:
+        """Return a number for how suitable a wheel is for the target Python
+
+        Higher numbers mean more specific (preferred) tags. 0 -> incompatible.
+        """
         m = re.search(r'-([^-]+)-([^-]+)-([^-]+)\.whl$', whl_filename)
         if not m:
             raise ValueError("Failed to find wheel tag in %r" % whl_filename)
 
         interpreter, abi, platform = m.group(1, 2, 3)
-        return (
-            self.score_platform(platform),
-            self.score_abi(abi),
-            self.score_interpreter(interpreter)
+        # Expand compressed tags ('cp38.cp39' indicates compatibility w/ both)
+        expanded_tags = itertools.product(
+            interpreter.split('.'), abi.split('.'), platform.split('.')
         )
+        return max(self.tag_prio.get(whl_tag, 0) for whl_tag in expanded_tags)
+
+    def is_compatible(self, whl_filename: str) -> bool:
+        return self.score(whl_filename) > 0
 
 class WheelLocator(object):
     def __init__(self, requirement, scorer, extra_sources=None):
@@ -77,14 +67,18 @@ class WheelLocator(object):
         self.name, self.version = requirement.split('==', 1)
 
     def pick_best_wheel(self, release_list):
-        best_score = (0, 0, 0)
+        """Return the most specific compatible wheel
+
+        Returns None if none of the supplied
+        """
+        best_score = 0
         best = None
         for release in release_list:
             if release.package_type != 'wheel':
                 continue
 
             score = self.scorer.score(release.filename)
-            if any(s==0 for s in score):
+            if score == 0:
                 # Incompatible
                 continue
 
@@ -333,8 +327,7 @@ class WheelGetter:
                              distribution, prev_path, whl_path))
 
         # Check that the wheel is compatible with the installer environment
-        scores = self.scorer.score(wheel_name)
-        if any(s == 0 for s in scores):
+        if not self.scorer.is_compatible(wheel_name):
             raise ValueError('Wheel {} is not compatible with Python {}, {}'
                 .format(wheel_name, self.scorer.py_version, self.scorer.platform))
 
@@ -365,3 +358,68 @@ def is_excluded(path, exclude_regexen):
         if re_pattern.match(path):
             return True
     return False
+
+# The function below is based on the packaging.tags module, used with
+# modification following the BSD 2 clause license:
+
+# Copyright (c) Donald Stufft and individual contributors.
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+def compatible_tags(python_version : tuple =None, platform : str =None):
+    """Iterate through compatible tags for our target Python
+
+    Tags are yielded in order from the most specific to the most general.
+
+    Based on packaging.tags module, but simplified for Pynsist's use case,
+    and avoiding getting any details from the currently running Python.
+    """
+    interpreter = "cp{}{}".format(python_version[0], python_version[1])
+
+    cpython_abi = interpreter
+    # Python is normally built with the pymalloc (m) option, and most wheels
+    # are published for this ABI. The flag is dropped in Python 3.8.
+    if python_version < (3, 8):
+        cpython_abi += 'm'
+
+    yield interpreter, cpython_abi, platform
+    yield interpreter, "abi3", platform
+    yield interpreter, "none", platform
+
+    # cp3x-abi3 down to cp32 (Python 3.2 was the first version to have ABI3)
+    for minor_version in range(python_version[1] - 1, 1, -1):
+        interpreter = "cp{}{}".format(python_version[0], minor_version)
+        yield interpreter, "abi3", platform
+
+    py_interps = [
+        f"py{python_version[0]}{python_version[1]}",  # e.g. py38
+        f"py{python_version[0]}",                     # py3
+    ] + [
+        f"py{python_version[0]}{minor}"               # py37 ... py30
+        for minor in range(python_version[1] - 1, -1, -1)
+    ]
+
+    for version in py_interps:
+        yield version, "none", platform
+    for version in py_interps:
+        yield version, "none", "any"

+ 1 - 1
pyproject.toml

@@ -9,7 +9,7 @@ author-email = "thomas@kluyver.me.uk"
 dist-name = "pynsist"
 home-page = "https://pynsist.readthedocs.io/en/latest/"
 description-file = "README.rst"
-requires-python = ">=3.5"
+requires-python = ">=3.6"
 requires = [
     "requests",
     "requests_download",

+ 0 - 1
tox.ini

@@ -4,7 +4,6 @@ envlist = python
 
 [gh-actions]
 python =
-    3.5: python
     3.6: python
     3.7: python
     3.8: python