瀏覽代碼

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

Handle wheel tag compatibility like packaging.tags
Thomas Kluyver 4 年之前
父節點
當前提交
863f0cce0e
共有 7 個文件被更改,包括 115 次插入51 次删除
  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
     runs-on: ubuntu-latest
     strategy:
     strategy:
       matrix:
       matrix:
-        python-version: [ 3.5, 3.6, 3.7, 3.8, 3.9, ]
+        python-version: [ 3.6, 3.7, 3.8, 3.9, ]
     steps:
     steps:
       - uses: actions/checkout@v2
       - 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
 installers bundle Python itself, so you can distribute your application to
 people who don't have Python installed.
 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/>`_
 For more information, see `the documentation <https://pynsist.readthedocs.io/en/latest/>`_
 and `the examples <https://github.com/takluyver/pynsist/tree/master/examples>`_.
 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
 Quickstart
 ----------
 ----------
 
 

+ 0 - 1
appveyor.yml

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

+ 16 - 9
nsist/tests/test_pypi.py

@@ -72,8 +72,8 @@ def test_pick_best_wheel():
 
 
     # Prefer more specific Python version
     # Prefer more specific Python version
     releases = [
     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]
     assert wd37.pick_best_wheel(releases) == releases[0]
 
 
@@ -93,22 +93,22 @@ def test_pick_best_wheel():
 
 
     # Prefer more specific ABI version
     # Prefer more specific ABI version
     releases = [
     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]
     assert wd37.pick_best_wheel(releases) == releases[0]
 
 
     # ABI suffix on Python <3.8
     # ABI suffix on Python <3.8
     releases = [
     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]
     assert wd37.pick_best_wheel(releases) == releases[1]
 
 
     # No ABI suffix on Python >=3.8
     # No ABI suffix on Python >=3.8
     releases = [
     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]
     assert wd38.pick_best_wheel(releases) == releases[0]
 
 
@@ -121,11 +121,18 @@ def test_pick_best_wheel():
 
 
     # Platform has priority over other attributes
     # Platform has priority over other attributes
     releases = [
     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'),
         CachedRelease('astsearch-0.1.2-py2.py3-none-win_amd64.whl'),
     ]
     ]
     assert wd37.pick_best_wheel(releases) == releases[1]
     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):
 def test_merge_dir_to(tmpdir):
     td1 = Path(str(tmpdir.mkdir('one')))
     td1 = Path(str(tmpdir.mkdir('one')))
     td2 = Path(str(tmpdir.mkdir('two')))
     td2 = Path(str(tmpdir.mkdir('two')))

+ 92 - 34
nsist/wheels.py

@@ -1,6 +1,7 @@
 """Find, download and unpack wheels."""
 """Find, download and unpack wheels."""
 import fnmatch
 import fnmatch
 import hashlib
 import hashlib
+import itertools
 import logging
 import logging
 import glob
 import glob
 import os
 import os
@@ -27,44 +28,33 @@ class CompatibilityScorer:
     """
     """
     def __init__(self, py_version, platform):
     def __init__(self, py_version, platform):
         self.py_version = py_version
         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
         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)
         m = re.search(r'-([^-]+)-([^-]+)-([^-]+)\.whl$', whl_filename)
         if not m:
         if not m:
             raise ValueError("Failed to find wheel tag in %r" % whl_filename)
             raise ValueError("Failed to find wheel tag in %r" % whl_filename)
 
 
         interpreter, abi, platform = m.group(1, 2, 3)
         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):
 class WheelLocator(object):
     def __init__(self, requirement, scorer, extra_sources=None):
     def __init__(self, requirement, scorer, extra_sources=None):
@@ -77,14 +67,18 @@ class WheelLocator(object):
         self.name, self.version = requirement.split('==', 1)
         self.name, self.version = requirement.split('==', 1)
 
 
     def pick_best_wheel(self, release_list):
     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
         best = None
         for release in release_list:
         for release in release_list:
             if release.package_type != 'wheel':
             if release.package_type != 'wheel':
                 continue
                 continue
 
 
             score = self.scorer.score(release.filename)
             score = self.scorer.score(release.filename)
-            if any(s==0 for s in score):
+            if score == 0:
                 # Incompatible
                 # Incompatible
                 continue
                 continue
 
 
@@ -333,8 +327,7 @@ class WheelGetter:
                              distribution, prev_path, whl_path))
                              distribution, prev_path, whl_path))
 
 
         # Check that the wheel is compatible with the installer environment
         # 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 {}, {}'
             raise ValueError('Wheel {} is not compatible with Python {}, {}'
                 .format(wheel_name, self.scorer.py_version, self.scorer.platform))
                 .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):
         if re_pattern.match(path):
             return True
             return True
     return False
     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"
 dist-name = "pynsist"
 home-page = "https://pynsist.readthedocs.io/en/latest/"
 home-page = "https://pynsist.readthedocs.io/en/latest/"
 description-file = "README.rst"
 description-file = "README.rst"
-requires-python = ">=3.5"
+requires-python = ">=3.6"
 requires = [
 requires = [
     "requests",
     "requests",
     "requests_download",
     "requests_download",

+ 0 - 1
tox.ini

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