diff options
-rw-r--r-- | CHANGES.txt | 15 | ||||
-rw-r--r-- | README.txt | 9 | ||||
-rw-r--r-- | tox.ini | 2 | ||||
-rw-r--r-- | wheel/__init__.py | 2 | ||||
-rw-r--r-- | wheel/archive.py | 26 | ||||
-rw-r--r-- | wheel/bdist_wheel.py | 49 | ||||
-rw-r--r-- | wheel/metadata.py | 13 | ||||
-rw-r--r-- | wheel/pep425tags.py | 97 | ||||
-rw-r--r-- | wheel/test/complex-dist/setup.py | 7 | ||||
-rw-r--r-- | wheel/test/test_tagopt.py | 69 | ||||
-rw-r--r-- | wheel/test/test_wheelfile.py | 71 |
11 files changed, 308 insertions, 52 deletions
diff --git a/CHANGES.txt b/CHANGES.txt index 26754c9..afee416 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,16 @@ +0.26.0 +====== +- Fix multiple entrypoint comparison failure on Python 3 (Issue #148) + +0.25.0 +====== +- Add Python 3.5 to tox configuration +- Deterministic (sorted) metadata +- Fix tagging for Python 3.5 compatibility +- Support py2-none-'arch' and py3-none-'arch' tags +- Treat data-only wheels as pure +- Write to temporary file and rename when using wheel install --force + 0.24.0 ====== - The python tag used for pure-python packages is now .pyN (major version @@ -14,7 +27,7 @@ 0.23.0 ====== -- Compatibiltiy tag flags added to the bdist_wheel command +- Compatibility tag flags added to the bdist_wheel command - sdist should include files necessary for tests - 'wheel convert' can now also convert unpacked eggs to wheel - Rename pydist.json to metadata.json to avoid stepping on the PEP @@ -39,3 +39,12 @@ Unlike .egg, wheel will be a fully-documented standard at the binary level that is truly easy to install even if you do not want to use the reference implementation. + +Code of Conduct +--------------- + +Everyone interacting in the wheel project's codebases, issue trackers, chat +rooms, and mailing lists is expected to follow the `PyPA Code of Conduct`_. + +.. _PyPA Code of Conduct: https://www.pypa.io/en/latest/code-of-conduct/ + @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py26, py27, pypy, py33, py34 +envlist = py26, py27, pypy, py33, py34, py35 [testenv] commands = diff --git a/wheel/__init__.py b/wheel/__init__.py index b84d0e4..5a57ba7 100644 --- a/wheel/__init__.py +++ b/wheel/__init__.py @@ -1,2 +1,2 @@ # __variables__ with double-quoted values will be available in setup.py: -__version__ = "0.25.0" +__version__ = "0.26.0" diff --git a/wheel/archive.py b/wheel/archive.py index 225d295..f4dd617 100644 --- a/wheel/archive.py +++ b/wheel/archive.py @@ -2,6 +2,8 @@ Archive tools for wheel. """ +import os +import time import logging import os.path import zipfile @@ -31,6 +33,15 @@ def make_wheelfile_inner(base_name, base_dir='.'): log.info("creating '%s' and adding '%s' to it", zip_filename, base_dir) + # Some applications need reproducible .whl files, but they can't do this + # without forcing the timestamp of the individual ZipInfo objects. See + # issue #143. + timestamp = os.environ.get('SOURCE_DATE_EPOCH') + if timestamp is None: + date_time = None + else: + date_time = time.gmtime(int(timestamp))[0:6] + # XXX support bz2, xz when available zip = zipfile.ZipFile(open(zip_filename, "wb+"), "w", compression=zipfile.ZIP_DEFLATED) @@ -38,8 +49,15 @@ def make_wheelfile_inner(base_name, base_dir='.'): score = {'WHEEL': 1, 'METADATA': 2, 'RECORD': 3} deferred = [] - def writefile(path): - zip.write(path, path) + def writefile(path, date_time): + if date_time is None: + st = os.stat(path) + mtime = time.gmtime(st.st_mtime) + date_time = mtime[0:6] + zinfo = zipfile.ZipInfo(path, date_time) + zinfo.external_attr = 0o100644 << 16 + with open(path, 'rb') as fp: + zip.writestr(zinfo, fp.read()) log.info("adding '%s'" % path) for dirpath, dirnames, filenames in os.walk(base_dir): @@ -50,11 +68,11 @@ def make_wheelfile_inner(base_name, base_dir='.'): if dirpath.endswith('.dist-info'): deferred.append((score.get(name, 0), path)) else: - writefile(path) + writefile(path, date_time) deferred.sort() for score, path in deferred: - writefile(path) + writefile(path, date_time) zip.close() diff --git a/wheel/bdist_wheel.py b/wheel/bdist_wheel.py index ac770e9..8920fce 100644 --- a/wheel/bdist_wheel.py +++ b/wheel/bdist_wheel.py @@ -33,7 +33,7 @@ from distutils.sysconfig import get_python_version from distutils import log as logger -from .pep425tags import get_abbr_impl, get_impl_ver +from .pep425tags import get_abbr_impl, get_impl_ver, get_abi_tag from .util import native, open_for_csv from .archive import archive_wheelfile from .pkginfo import read_pkg_info, write_pkg_info @@ -52,9 +52,12 @@ class bdist_wheel(Command): user_options = [('bdist-dir=', 'b', "temporary directory for creating the distribution"), - ('plat-name=', 'p', - "platform name to embed in generated filenames " + ('plat-tag=', 'p', + "platform tag to embed in generated filenames " "(default: %s)" % get_platform()), + ('plat-name=', None, + "DEPRECATED. Platform tag to embed in generated " + "filenames (default: %s)" % get_platform()), ('keep-temp', 'k', "keep the pseudo-installation tree around after " + "creating the distribution archive"), @@ -85,6 +88,7 @@ class bdist_wheel(Command): self.bdist_dir = None self.data_dir = None self.plat_name = None + self.plat_tag = None self.format = 'zip' self.keep_temp = False self.dist_dir = None @@ -128,30 +132,27 @@ class bdist_wheel(Command): safer_version(self.distribution.get_version()))) def get_tag(self): - supported_tags = pep425tags.get_supported() + plat_tag = self.plat_tag or self.plat_name + if plat_tag: + plat_tag = plat_tag.replace('-', '_').replace('.', '_') + supported_tags = pep425tags.get_supported(supplied_platform=plat_tag) if self.root_is_pure: if self.universal: impl = 'py2.py3' else: impl = self.python_tag - tag = (impl, 'none', 'any') + if not plat_tag: + plat_tag = 'any' + tag = (impl, 'none', plat_tag) else: - plat_name = self.plat_name - if plat_name is None: - plat_name = get_platform() - plat_name = plat_name.replace('-', '_').replace('.', '_') impl_name = get_abbr_impl() impl_ver = get_impl_ver() - # PEP 3149 -- no SOABI in Py 2 - # For PyPy? - # "pp%s%s" % (sys.pypy_version_info.major, - # sys.pypy_version_info.minor) - abi_tag = sysconfig.get_config_vars().get('SOABI', 'none') - if abi_tag.startswith('cpython-'): - abi_tag = 'cp' + abi_tag.rsplit('-', 1)[-1] - - tag = (impl_name + impl_ver, abi_tag, plat_name) + # PEP 3149 + abi_tag = str(get_abi_tag()).lower() + if not plat_tag: + plat_tag = get_platform().replace('-', '_').replace('.', '_') + tag = (impl_name + impl_ver, abi_tag, plat_tag) # XXX switch to this alternate implementation for non-pure: assert tag == supported_tags[0] return tag @@ -200,7 +201,7 @@ class bdist_wheel(Command): if os.name == 'nt': # win32 barfs if any of these are ''; could be '.'? # (distutils.command.install:change_roots bug) - basedir_observed = os.path.join(self.data_dir, '..') + basedir_observed = os.path.normpath(os.path.join(self.data_dir, '..')) self.install_libbase = self.install_lib = basedir_observed setattr(install, @@ -378,9 +379,11 @@ class bdist_wheel(Command): 'not-zip-safe',))) # delete dependency_links if it is only whitespace - dependency_links = os.path.join(distinfo_path, 'dependency_links.txt') - if not open(dependency_links, 'r').read().strip(): - adios(dependency_links) + dependency_links_path = os.path.join(distinfo_path, 'dependency_links.txt') + with open(dependency_links_path, 'r') as dependency_links_file: + dependency_links = dependency_links_file.read().strip() + if not dependency_links: + adios(dependency_links_path) write_pkg_info(os.path.join(distinfo_path, 'METADATA'), pkg_info) @@ -410,7 +413,7 @@ class bdist_wheel(Command): pymeta['extensions']['python.details']['document_names']['license'] = license_filename with open(metadata_json_path, "w") as metadata_json: - json.dump(pymeta, metadata_json) + json.dump(pymeta, metadata_json, sort_keys=True) adios(egginfo_path) diff --git a/wheel/metadata.py b/wheel/metadata.py index 02c0663..b3cc65c 100644 --- a/wheel/metadata.py +++ b/wheel/metadata.py @@ -74,7 +74,14 @@ def handle_requires(metadata, pkg_info, key): if may_requires: metadata['run_requires'] = [] - for key, value in may_requires.items(): + def sort_key(item): + # Both condition and extra could be None, which can't be compared + # against strings in Python 3. + key, value = item + if key.condition is None: + return '' + return key.condition + for key, value in sorted(may_requires.items(), key=sort_key): may_requirement = OrderedDict((('requires', value),)) if key.extra: may_requirement['extra'] = key.extra @@ -191,8 +198,8 @@ def pkginfo_to_dict(path, distribution=None): exports = OrderedDict() for group, items in sorted(ep_map.items()): exports[group] = OrderedDict() - for item in sorted(items.values()): - name, export = str(item).split(' = ', 1) + for item in sorted(map(str, items.values())): + name, export = item.split(' = ', 1) exports[group][name] = export if exports: metadata['extensions']['python.exports'] = exports diff --git a/wheel/pep425tags.py b/wheel/pep425tags.py index a6220ea..106c879 100644 --- a/wheel/pep425tags.py +++ b/wheel/pep425tags.py @@ -1,6 +1,7 @@ """Generate and work with PEP 425 Compatibility Tags.""" import sys +import warnings try: import sysconfig @@ -10,6 +11,14 @@ except ImportError: # pragma nocover import distutils.util +def get_config_var(var): + try: + return sysconfig.get_config_var(var) + except IOError as e: # pip Issue #1074 + warnings.warn("{0}".format(e), RuntimeWarning) + return None + + def get_abbr_impl(): """Return abbreviated implementation name.""" if hasattr(sys, 'pypy_version_info'): @@ -25,19 +34,76 @@ def get_abbr_impl(): def get_impl_ver(): """Return implementation version.""" - impl_ver = sysconfig.get_config_var("py_version_nodot") - if not impl_ver: - impl_ver = ''.join(map(str, sys.version_info[:2])) + impl_ver = get_config_var("py_version_nodot") + if not impl_ver or get_abbr_impl() == 'pp': + impl_ver = ''.join(map(str, get_impl_version_info())) return impl_ver +def get_impl_version_info(): + """Return sys.version_info-like tuple for use in decrementing the minor + version.""" + if get_abbr_impl() == 'pp': + # as per https://github.com/pypa/pip/issues/2882 + return (sys.version_info[0], sys.pypy_version_info.major, + sys.pypy_version_info.minor) + else: + return sys.version_info[0], sys.version_info[1] + + +def get_flag(var, fallback, expected=True, warn=True): + """Use a fallback method for determining SOABI flags if the needed config + var is unset or unavailable.""" + val = get_config_var(var) + if val is None: + if warn: + warnings.warn("Config variable '{0}' is unset, Python ABI tag may " + "be incorrect".format(var), RuntimeWarning, 2) + return fallback() + return val == expected + + +def get_abi_tag(): + """Return the ABI tag based on SOABI (if available) or emulate SOABI + (CPython 2, PyPy).""" + soabi = get_config_var('SOABI') + impl = get_abbr_impl() + if not soabi and impl in ('cp', 'pp') and hasattr(sys, 'maxunicode'): + d = '' + m = '' + u = '' + if get_flag('Py_DEBUG', + lambda: hasattr(sys, 'gettotalrefcount'), + warn=(impl == 'cp')): + d = 'd' + if get_flag('WITH_PYMALLOC', + lambda: impl == 'cp', + warn=(impl == 'cp')): + m = 'm' + if get_flag('Py_UNICODE_SIZE', + lambda: sys.maxunicode == 0x10ffff, + expected=4, + warn=(impl == 'cp' and + sys.version_info < (3, 3))) \ + and sys.version_info < (3, 3): + u = 'u' + abi = '%s%s%s%s%s' % (impl, get_impl_ver(), d, m, u) + elif soabi and soabi.startswith('cpython-'): + abi = 'cp' + soabi.split('-')[1] + elif soabi: + abi = soabi.replace('.', '_').replace('-', '_') + else: + abi = None + return abi + + def get_platform(): """Return our platform name 'win32', 'linux_x86_64'""" # XXX remove distutils dependency return distutils.util.get_platform().replace('.', '_').replace('-', '_') -def get_supported(versions=None): +def get_supported(versions=None, supplied_platform=None): """Return a list of supported tags for each version specified in `versions`. @@ -49,18 +115,19 @@ def get_supported(versions=None): # Versions must be given with respect to the preference if versions is None: versions = [] - major = sys.version_info[0] + version_info = get_impl_version_info() + major = version_info[:-1] # Support all previous minor Python versions. - for minor in range(sys.version_info[1], -1, -1): - versions.append(''.join(map(str, (major, minor)))) + for minor in range(version_info[-1], -1, -1): + versions.append(''.join(map(str, major + (minor,)))) impl = get_abbr_impl() abis = [] - soabi = sysconfig.get_config_var('SOABI') - if soabi and soabi.startswith('cpython-'): - abis[0:0] = ['cp' + soabi.split('-', 1)[-1]] + abi = get_abi_tag() + if abi: + abis[0:0] = [abi] abi3s = set() import imp @@ -72,11 +139,15 @@ def get_supported(versions=None): abis.append('none') - arch = get_platform() + platforms = [] + if supplied_platform: + platforms.append(supplied_platform) + platforms.append(get_platform()) # Current version, current API (built specifically for our Python): for abi in abis: - supported.append(('%s%s' % (impl, versions[0]), abi, arch)) + for arch in platforms: + supported.append(('%s%s' % (impl, versions[0]), abi, arch)) # No abi / arch, but requires our implementation: for i, version in enumerate(versions): @@ -96,5 +167,3 @@ def get_supported(versions=None): supported.append(('py%s' % (version[0]), 'none', 'any')) return supported - - diff --git a/wheel/test/complex-dist/setup.py b/wheel/test/complex-dist/setup.py index 802dbaf..615d5dc 100644 --- a/wheel/test/complex-dist/setup.py +++ b/wheel/test/complex-dist/setup.py @@ -20,6 +20,11 @@ setup(name='complex-dist', install_requires=["quux", "splort"], extras_require={'simple':['simple.dist']}, tests_require=["foo", "bar>=10.0.0"], - entry_points={'console_scripts':['complex-dist=complexdist:main']} + entry_points={ + 'console_scripts': [ + 'complex-dist=complexdist:main', + 'complex-dist2=complexdist:main', + ], + }, ) diff --git a/wheel/test/test_tagopt.py b/wheel/test/test_tagopt.py index 300fcf9..a8de089 100644 --- a/wheel/test/test_tagopt.py +++ b/wheel/test/test_tagopt.py @@ -10,27 +10,39 @@ import tempfile import subprocess SETUP_PY = """\ -from setuptools import setup +from setuptools import setup, Extension setup( name="Test", version="1.0", author_email="author@example.com", py_modules=["test"], + {ext_modules} ) """ +EXT_MODULES = "ext_modules=[Extension('_test', sources=['test.c'])]," + @pytest.fixture -def temp_pkg(request): +def temp_pkg(request, ext=False): tempdir = tempfile.mkdtemp() def fin(): shutil.rmtree(tempdir) request.addfinalizer(fin) temppath = py.path.local(tempdir) temppath.join('test.py').write('print("Hello, world")') - temppath.join('setup.py').write(SETUP_PY) + if ext: + temppath.join('test.c').write('#include <stdio.h>') + setup_py = SETUP_PY.format(ext_modules=EXT_MODULES) + else: + setup_py = SETUP_PY.format(ext_modules='') + temppath.join('setup.py').write(setup_py) return temppath +@pytest.fixture +def temp_ext_pkg(request): + return temp_pkg(request, ext=True) + def test_default_tag(temp_pkg): subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel'], cwd=str(temp_pkg)) @@ -110,3 +122,54 @@ def test_legacy_wheel_section_in_setup_cfg(temp_pkg): assert wheels[0].basename.startswith('Test-1.0-py2.py3-') assert wheels[0].ext == '.whl' +def test_plat_tag_purepy(temp_pkg): + subprocess.check_call( + [sys.executable, 'setup.py', 'bdist_wheel', '--plat-tag=testplat.pure'], + cwd=str(temp_pkg)) + dist_dir = temp_pkg.join('dist') + assert dist_dir.check(dir=1) + wheels = dist_dir.listdir() + assert len(wheels) == 1 + assert wheels[0].basename.endswith('-testplat_pure.whl') + assert wheels[0].ext == '.whl' + +def test_plat_tag_ext(temp_ext_pkg): + try: + subprocess.check_call( + [sys.executable, 'setup.py', 'bdist_wheel', '--plat-tag=testplat.arch'], + cwd=str(temp_ext_pkg)) + except subprocess.CalledProcessError: + pytest.skip("Cannot compile C Extensions") + dist_dir = temp_ext_pkg.join('dist') + assert dist_dir.check(dir=1) + wheels = dist_dir.listdir() + assert len(wheels) == 1 + assert wheels[0].basename.endswith('-testplat_arch.whl') + assert wheels[0].ext == '.whl' + +def test_plat_tag_purepy_in_setupcfg(temp_pkg): + temp_pkg.join('setup.cfg').write('[bdist_wheel]\nplat_tag=testplat.pure') + subprocess.check_call( + [sys.executable, 'setup.py', 'bdist_wheel'], + cwd=str(temp_pkg)) + dist_dir = temp_pkg.join('dist') + assert dist_dir.check(dir=1) + wheels = dist_dir.listdir() + assert len(wheels) == 1 + assert wheels[0].basename.endswith('-testplat_pure.whl') + assert wheels[0].ext == '.whl' + +def test_plat_tag_ext_in_setupcfg(temp_ext_pkg): + temp_ext_pkg.join('setup.cfg').write('[bdist_wheel]\nplat_tag=testplat.arch') + try: + subprocess.check_call( + [sys.executable, 'setup.py', 'bdist_wheel'], + cwd=str(temp_ext_pkg)) + except subprocess.CalledProcessError: + pytest.skip("Cannot compile C Extensions") + dist_dir = temp_ext_pkg.join('dist') + assert dist_dir.check(dir=1) + wheels = dist_dir.listdir() + assert len(wheels) == 1 + assert wheels[0].basename.endswith('-testplat_arch.whl') + assert wheels[0].ext == '.whl' diff --git a/wheel/test/test_wheelfile.py b/wheel/test/test_wheelfile.py index e362ceb..59bbb4c 100644 --- a/wheel/test/test_wheelfile.py +++ b/wheel/test/test_wheelfile.py @@ -1,11 +1,48 @@ +import os import wheel.install +import wheel.archive import hashlib try: from StringIO import StringIO except ImportError: from io import BytesIO as StringIO +import codecs import zipfile import pytest +import shutil +import tempfile +from contextlib import contextmanager + +@contextmanager +def environ(key, value): + old_value = os.environ.get(key) + try: + os.environ[key] = value + yield + finally: + if old_value is None: + del os.environ[key] + else: + os.environ[key] = old_value + +@contextmanager +def temporary_directory(): + # tempfile.TemporaryDirectory doesn't exist in Python 2. + tempdir = tempfile.mkdtemp() + try: + yield tempdir + finally: + shutil.rmtree(tempdir) + +@contextmanager +def readable_zipfile(path): + # zipfile.ZipFile() isn't a context manager under Python 2. + zf = zipfile.ZipFile(path, 'r') + try: + yield zf + finally: + zf.close() + def test_verifying_zipfile(): if not hasattr(zipfile.ZipExtFile, '_update_crc'): @@ -66,4 +103,36 @@ def test_pop_zipfile(): zf = wheel.install.VerifyingZipFile(sio, 'r') assert len(zf.infolist()) == 1 -
\ No newline at end of file + +def test_zipfile_timestamp(): + # An environment variable can be used to influence the timestamp on + # TarInfo objects inside the zip. See issue #143. TemporaryDirectory is + # not a context manager under Python 3. + with temporary_directory() as tempdir: + for filename in ('one', 'two', 'three'): + path = os.path.join(tempdir, filename) + with codecs.open(path, 'w', encoding='utf-8') as fp: + fp.write(filename + '\n') + zip_base_name = os.path.join(tempdir, 'dummy') + # The earliest date representable in TarInfos, 1980-01-01 + with environ('SOURCE_DATE_EPOCH', '315576060'): + zip_filename = wheel.archive.make_wheelfile_inner( + zip_base_name, tempdir) + with readable_zipfile(zip_filename) as zf: + for info in zf.infolist(): + assert info.date_time[:3] == (1980, 1, 1) + +def test_zipfile_attributes(): + # With the change from ZipFile.write() to .writestr(), we need to manually + # set member attributes. Per existing tradition file permissions are forced + # to 0o644, although in the future we may want to preserve executable bits. + with temporary_directory() as tempdir: + path = os.path.join(tempdir, 'foo') + with codecs.open(path, 'w', encoding='utf-8') as fp: + fp.write('foo\n') + zip_base_name = os.path.join(tempdir, 'dummy') + zip_filename = wheel.archive.make_wheelfile_inner( + zip_base_name, tempdir) + with readable_zipfile(zip_filename) as zf: + for info in zf.infolist(): + assert info.external_attr == 0o100644 << 16 |