[bumpversion]
-current_version = 61.3.1
+current_version = 62.0.0
commit = True
tag = True
+v62.0.0
+-------
+
+
+Breaking Changes
+^^^^^^^^^^^^^^^^
+* #3151: Made ``setup.py develop --user`` install to the user site packages directory even if it is disabled in the current interpreter.
+
+Changes
+^^^^^^^
+* #3153: When resolving requirements use both canonical and normalized names -- by :user:`ldaniluk`
+* #3167: Honor unix file mode in ZipFile when installing wheel via ``install_as_egg`` -- by :user:`delijati`
+
+Misc
+^^^^
+* #3088: Fixed duplicated tag with the ``dist-info`` command.
+* #3247: Fixed problem preventing ``readme`` specified as dynamic in ``pyproject.toml``
+ from being dynamically specified in ``setup.py``.
+
+
v61.3.1
-------
__import__('pkg_resources.extern.packaging.specifiers')
__import__('pkg_resources.extern.packaging.requirements')
__import__('pkg_resources.extern.packaging.markers')
+__import__('pkg_resources.extern.packaging.utils')
if sys.version_info < (3, 5):
raise RuntimeError("Python 3.5 or later is required")
self.entries = []
self.entry_keys = {}
self.by_key = {}
+ self.normalized_to_canonical_keys = {}
self.callbacks = []
if entries is None:
is returned.
"""
dist = self.by_key.get(req.key)
+
+ if dist is None:
+ canonical_key = self.normalized_to_canonical_keys.get(req.key)
+
+ if canonical_key is not None:
+ req.key = canonical_key
+ dist = self.by_key.get(canonical_key)
+
if dist is not None and dist not in req:
# XXX add more info
raise VersionConflict(dist, req)
return
self.by_key[dist.key] = dist
+ normalized_name = packaging.utils.canonicalize_name(dist.key)
+ self.normalized_to_canonical_keys[normalized_name] = dist.key
if dist.key not in keys:
keys.append(dist.key)
if dist.key not in keys2:
def __getstate__(self):
return (
self.entries[:], self.entry_keys.copy(), self.by_key.copy(),
- self.callbacks[:]
+ self.normalized_to_canonical_keys.copy(), self.callbacks[:]
)
- def __setstate__(self, e_k_b_c):
- entries, keys, by_key, callbacks = e_k_b_c
+ def __setstate__(self, e_k_b_n_c):
+ entries, keys, by_key, normalized_to_canonical_keys, callbacks = e_k_b_n_c
self.entries = entries[:]
self.entry_keys = keys.copy()
self.by_key = by_key.copy()
+ self.normalized_to_canonical_keys = normalized_to_canonical_keys.copy()
self.callbacks = callbacks[:]
continue
fields = spec.split('\n', 1)
assert 1 <= len(fields) <= 2
- name, version = fields.pop(0).split('-')
+ name, version = fields.pop(0).rsplit('-', 1)
if fields:
requires = textwrap.dedent(fields.pop(0))
metadata = Metadata(('requires.txt', requires))
# resolved [replace conflicting]
VersionConflict
''',
+
+ '''
+ # id
+ wanted_normalized_name_installed_canonical
+
+ # installed
+ foo.bar-3.6
+
+ # installable
+
+ # wanted
+ foo-bar==3.6
+
+ # resolved
+ foo.bar-3.6
+
+ # resolved [replace conflicting]
+ foo.bar-3.6
+ ''',
)
def test_working_set_resolve(installed_dists, installable_dists, requirements,
replace_conflicting, resolved_dists_or_exception):
[metadata]
name = setuptools
-version = 61.3.1
+version = 62.0.0
author = Python Packaging Authority
author_email = distutils-sig@python.org
description = Easily download, build, install, upgrade, and uninstall Python packages
raise UnrecognizedFormat("%s is not a zip file" % (filename,))
with zipfile.ZipFile(filename) as z:
- for info in z.infolist():
- name = info.filename
+ _unpack_zipfile_obj(z, extract_dir, progress_filter)
- # don't extract absolute paths or ones with .. in them
- if name.startswith('/') or '..' in name.split('/'):
- continue
- target = os.path.join(extract_dir, *name.split('/'))
- target = progress_filter(name, target)
- if not target:
- continue
- if name.endswith('/'):
- # directory
- ensure_directory(target)
- else:
- # file
- ensure_directory(target)
- data = z.read(info.filename)
- with open(target, 'wb') as f:
- f.write(data)
- unix_attributes = info.external_attr >> 16
- if unix_attributes:
- os.chmod(target, unix_attributes)
+def _unpack_zipfile_obj(zipfile_obj, extract_dir, progress_filter=default_filter):
+ """Internal/private API used by other parts of setuptools.
+ Similar to ``unpack_zipfile``, but receives an already opened :obj:`zipfile.ZipFile`
+ object instead of a filename.
+ """
+ for info in zipfile_obj.infolist():
+ name = info.filename
+
+ # don't extract absolute paths or ones with .. in them
+ if name.startswith('/') or '..' in name.split('/'):
+ continue
+
+ target = os.path.join(extract_dir, *name.split('/'))
+ target = progress_filter(name, target)
+ if not target:
+ continue
+ if name.endswith('/'):
+ # directory
+ ensure_directory(target)
+ else:
+ # file
+ ensure_directory(target)
+ data = zipfile_obj.read(info.filename)
+ with open(target, 'wb') as f:
+ f.write(data)
+ unix_attributes = info.external_attr >> 16
+ if unix_attributes:
+ os.chmod(target, unix_attributes)
def _resolve_tar_file_or_dir(tar_obj, tar_member_obj):
"""
import os
+import re
+import warnings
+from inspect import cleandoc
from distutils.core import Command
from distutils import log
+from setuptools.extern import packaging
class dist_info(Command):
egg_info.egg_base = self.egg_base
egg_info.finalize_options()
egg_info.run()
- dist_info_dir = egg_info.egg_info[:-len('.egg-info')] + '.dist-info'
+ name = _safe(self.distribution.get_name())
+ version = _version(self.distribution.get_version())
+ base = self.egg_base or os.curdir
+ dist_info_dir = os.path.join(base, f"{name}-{version}.dist-info")
log.info("creating '{}'".format(os.path.abspath(dist_info_dir)))
bdist_wheel = self.get_finalized_command('bdist_wheel')
bdist_wheel.egg2dist(egg_info.egg_info, dist_info_dir)
+
+
+def _safe(component: str) -> str:
+ """Escape a component used to form a wheel name according to PEP 491"""
+ return re.sub(r"[^\w\d.]+", "_", component)
+
+
+def _version(version: str) -> str:
+ """Convert an arbitrary string to a version string."""
+ v = version.replace(' ', '.')
+ try:
+ return str(packaging.version.Version(v)).replace("-", "_")
+ except packaging.version.InvalidVersion:
+ msg = f"""!!\n\n
+ ###################
+ # Invalid version #
+ ###################
+ {version!r} is not valid according to PEP 440.\n
+ Please make sure specify a valid version for your package.
+ Also note that future releases of setuptools may halt the build process
+ if an invalid version is given.
+ \n\n!!
+ """
+ warnings.warn(cleandoc(msg))
+ return _safe(v).strip("_")
self.install_data = None
self.install_base = None
self.install_platbase = None
- if site.ENABLE_USER_SITE:
- self.install_userbase = site.USER_BASE
- self.install_usersite = site.USER_SITE
- else:
- self.install_userbase = None
- self.install_usersite = None
+ self.install_userbase = site.USER_BASE
+ self.install_usersite = site.USER_SITE
self.no_find_links = None
# Options not specifiable via command line
getattr(sys, 'windir', '').replace('.', ''),
)
- if site.ENABLE_USER_SITE:
- self.config_vars['userbase'] = self.install_userbase
- self.config_vars['usersite'] = self.install_usersite
-
- elif self.user:
+ self.config_vars['userbase'] = self.install_userbase
+ self.config_vars['usersite'] = self.install_usersite
+ if self.user and not site.ENABLE_USER_SITE:
log.warn("WARNING: The user site-packages directory is disabled.")
self._fix_install_dir_for_user_site()
"""
Fix the install_dir if "--user" was used.
"""
- if not self.user or not site.ENABLE_USER_SITE:
+ if not self.user:
return
self.create_home_path()
in which case the version string already contains all tags.
"""
return (
- version if self.vtags and version.endswith(self.vtags)
+ version if self.vtags and self._already_tagged(version)
else version + self.vtags
)
- def tags(self):
+ def _already_tagged(self, version: str) -> bool:
+ # Depending on their format, tags may change with version normalization.
+ # So in addition the regular tags, we have to search for the normalized ones.
+ return version.endswith(self.vtags) or version.endswith(self._safe_tags())
+
+ def _safe_tags(self) -> str:
+ # To implement this we can rely on `safe_version` pretending to be version 0
+ # followed by tags. Then we simply discard the starting 0 (fake version number)
+ return safe_version(f"0{self.vtags}")[1:]
+
+ def tags(self) -> str:
version = ''
if self.tag_build:
version += self.tag_build
return None
def _obtain_readme(self, dist: "Distribution") -> Optional[Dict[str, str]]:
- if "readme" in self.dynamic:
- dynamic_cfg = self.dynamic_cfg
+ if "readme" not in self.dynamic:
+ return None
+
+ dynamic_cfg = self.dynamic_cfg
+ if "readme" in dynamic_cfg:
return {
"text": self._obtain(dist, "readme", {}),
"content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
}
+
+ self._ensure_previously_set(dist, "readme")
return None
def _obtain_entry_points(
with pytest.raises(OptionError, match="No configuration .* .classifiers."):
read_configuration(pyproject)
+ def test_dynamic_readme_from_setup_script_args(self, tmp_path):
+ config = """
+ [project]
+ name = "myproj"
+ version = '42'
+ dynamic = ["readme"]
+ """
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_text(cleandoc(config))
+ dist = Distribution(attrs={"long_description": "42"})
+ # No error should occur because of missing `readme`
+ dist = apply_configuration(dist, pyproject)
+ assert dist.metadata.long_description == "42"
+
def test_dynamic_without_file(self, tmp_path):
config = """
[project]
"""Test .dist-info style distributions.
"""
+import pathlib
+import re
+import subprocess
+import sys
+from functools import partial
import pytest
import pkg_resources
+from setuptools.archive_util import unpack_archive
from .textwrap import DALS
+read = partial(pathlib.Path.read_text, encoding="utf-8")
+
+
class TestDistInfo:
metadata_base = DALS("""
pkg_resources.Requirement.parse('quux>=1.1;extra=="baz"'),
]
assert d.extras == ['baz']
+
+ def test_invalid_version(self, tmp_path):
+ config = "[metadata]\nname=proj\nversion=42\n[egg_info]\ntag_build=invalid!!!\n"
+ (tmp_path / "setup.cfg").write_text(config, encoding="utf-8")
+ msg = re.compile("invalid version", re.M | re.I)
+ output = run_command("dist_info", cwd=tmp_path)
+ assert msg.search(output)
+ dist_info = next(tmp_path.glob("*.dist-info"))
+ assert dist_info.name.startswith("proj-42")
+
+
+class TestWheelCompatibility:
+ """Make sure the .dist-info directory produced with the ``dist_info`` command
+ is the same as the one produced by ``bdist_wheel``.
+ """
+ SETUPCFG = DALS("""
+ [metadata]
+ name = {name}
+ version = {version}
+
+ [options]
+ install_requires = foo>=12; sys_platform != "linux"
+
+ [options.extras_require]
+ test = pytest
+
+ [options.entry_points]
+ console_scripts =
+ executable-name = my_package.module:function
+ discover =
+ myproj = my_package.other_module:function
+ """)
+
+ EGG_INFO_OPTS = [
+ # Related: #3088 #2872
+ ("", ""),
+ (".post", "[egg_info]\ntag_build = post\n"),
+ (".post", "[egg_info]\ntag_build = .post\n"),
+ (".post", "[egg_info]\ntag_build = post\ntag_date = 1\n"),
+ (".dev", "[egg_info]\ntag_build = .dev\n"),
+ (".dev", "[egg_info]\ntag_build = .dev\ntag_date = 1\n"),
+ ("a1", "[egg_info]\ntag_build = .a1\n"),
+ ("+local", "[egg_info]\ntag_build = +local\n"),
+ ]
+
+ @pytest.mark.parametrize("name", "my-proj my_proj my.proj My.Proj".split())
+ @pytest.mark.parametrize("version", ["0.42.13"])
+ @pytest.mark.parametrize("suffix, cfg", EGG_INFO_OPTS)
+ def test_dist_info_is_the_same_as_in_wheel(
+ self, name, version, tmp_path, suffix, cfg
+ ):
+ config = self.SETUPCFG.format(name=name, version=version) + cfg
+
+ for i in "dir_wheel", "dir_dist":
+ (tmp_path / i).mkdir()
+ (tmp_path / i / "setup.cfg").write_text(config, encoding="utf-8")
+
+ run_command("bdist_wheel", cwd=tmp_path / "dir_wheel")
+ wheel = next(tmp_path.glob("dir_wheel/dist/*.whl"))
+ unpack_archive(wheel, tmp_path / "unpack")
+ wheel_dist_info = next(tmp_path.glob("unpack/*.dist-info"))
+
+ run_command("dist_info", cwd=tmp_path / "dir_dist")
+ dist_info = next(tmp_path.glob("dir_dist/*.dist-info"))
+
+ assert dist_info.name == wheel_dist_info.name
+ assert dist_info.name.startswith(f"{name.replace('-', '_')}-{version}{suffix}")
+ for file in "METADATA", "entry_points.txt":
+ assert read(dist_info / file) == read(wheel_dist_info / file)
+
+
+def run_command(*cmd, **kwargs):
+ opts = {"stderr": subprocess.STDOUT, "text": True, **kwargs}
+ cmd = [sys.executable, "-c", "__import__('setuptools').setup()", *cmd]
+ return subprocess.check_output(cmd, **opts)
import pathlib
import warnings
from collections import namedtuple
+from pathlib import Path
import pytest
from jaraco import path
assert cmd.config_vars['py_version'] == '3.10.1'
assert cmd.config_vars['py_version_short'] == '3.10'
assert cmd.config_vars['py_version_nodot'] == '310'
+
+
+def test_editable_user_and_build_isolation(setup_context, monkeypatch, tmp_path):
+ ''' `setup.py develop` should honor `--user` even under build isolation'''
+
+ # == Arrange ==
+ # Pretend that build isolation was enabled
+ # e.g pip sets the environment varible PYTHONNOUSERSITE=1
+ monkeypatch.setattr('site.ENABLE_USER_SITE', False)
+
+ # Patching $HOME for 2 reasons:
+ # 1. setuptools/command/easy_install.py:create_home_path
+ # tries creating directories in $HOME
+ # given `self.config_vars['DESTDIRS'] = "/home/user/.pyenv/versions/3.9.10 /home/user/.pyenv/versions/3.9.10/lib /home/user/.pyenv/versions/3.9.10/lib/python3.9 /home/user/.pyenv/versions/3.9.10/lib/python3.9/lib-dynload"`` # noqa: E501
+ # it will `makedirs("/home/user/.pyenv/versions/3.9.10 /home/user/.pyenv/versions/3.9.10/lib /home/user/.pyenv/versions/3.9.10/lib/python3.9 /home/user/.pyenv/versions/3.9.10/lib/python3.9/lib-dynload")`` # noqa: E501
+ # 2. We are going to force `site` to update site.USER_BASE and site.USER_SITE
+ # To point inside our new home
+ monkeypatch.setenv('HOME', str(tmp_path / '.home'))
+ monkeypatch.setenv('USERPROFILE', str(tmp_path / '.home'))
+ monkeypatch.setenv('APPDATA', str(tmp_path / '.home'))
+ monkeypatch.setattr('site.USER_BASE', None)
+ monkeypatch.setattr('site.USER_SITE', None)
+ user_site = Path(site.getusersitepackages())
+ user_site.mkdir(parents=True, exist_ok=True)
+
+ sys_prefix = (tmp_path / '.sys_prefix')
+ sys_prefix.mkdir(parents=True, exist_ok=True)
+ monkeypatch.setattr('sys.prefix', str(sys_prefix))
+
+ setup_script = (
+ "__import__('setuptools').setup(name='aproj', version=42, packages=[])\n"
+ )
+ (tmp_path / "setup.py").write_text(setup_script, encoding="utf-8")
+
+ # == Sanity check ==
+ assert list(sys_prefix.glob("*")) == []
+ assert list(user_site.glob("*")) == []
+
+ # == Act ==
+ run_setup('setup.py', ['develop', '--user'])
+
+ # == Assert ==
+ # Should not install to sys.prefix
+ assert list(sys_prefix.glob("*")) == []
+ # Should install to user site
+ installed = {f.name for f in user_site.glob("*")}
+ # sometimes easy-install.pth is created and sometimes not
+ installed = installed - {"easy-install.pth"}
+ assert installed == {'aproj.egg-link'}
from distutils.sysconfig import get_config_var
from distutils.util import get_platform
import contextlib
+import pathlib
+import stat
import glob
import inspect
import os
monkeypatch.setattr('setuptools.wheel.sys_tags', sys_tags)
assert Wheel(
'onnxruntime-0.1.2-cp36-cp36m-manylinux1_x86_64.whl').is_compatible()
+
+
+def test_wheel_mode():
+ @contextlib.contextmanager
+ def build_wheel(extra_file_defs=None, **kwargs):
+ file_defs = {
+ 'setup.py': (DALS(
+ '''
+ # -*- coding: utf-8 -*-
+ from setuptools import setup
+ import setuptools
+ setup(**%r)
+ '''
+ ) % kwargs).encode('utf-8'),
+ }
+ if extra_file_defs:
+ file_defs.update(extra_file_defs)
+ with tempdir() as source_dir:
+ path.build(file_defs, source_dir)
+ runsh = pathlib.Path(source_dir) / "script.sh"
+ os.chmod(runsh, 0o777)
+ subprocess.check_call((sys.executable, 'setup.py',
+ '-q', 'bdist_wheel'), cwd=source_dir)
+ yield glob.glob(os.path.join(source_dir, 'dist', '*.whl'))[0]
+
+ params = dict(
+ id='script',
+ file_defs={
+ 'script.py': DALS(
+ '''
+ #/usr/bin/python
+ print('hello world!')
+ '''
+ ),
+ 'script.sh': DALS(
+ '''
+ #/bin/sh
+ echo 'hello world!'
+ '''
+ ),
+ },
+ setup_kwargs=dict(
+ scripts=['script.py', 'script.sh'],
+ ),
+ install_tree=flatten_tree({
+ 'foo-1.0-py{py_version}.egg': {
+ 'EGG-INFO': [
+ 'PKG-INFO',
+ 'RECORD',
+ 'WHEEL',
+ 'top_level.txt',
+ {'scripts': [
+ 'script.py',
+ 'script.sh'
+ ]}
+
+ ]
+ }
+ })
+ )
+
+ project_name = params.get('name', 'foo')
+ version = params.get('version', '1.0')
+ install_tree = params.get('install_tree')
+ file_defs = params.get('file_defs', {})
+ setup_kwargs = params.get('setup_kwargs', {})
+
+ with build_wheel(
+ name=project_name,
+ version=version,
+ install_requires=[],
+ extras_require={},
+ extra_file_defs=file_defs,
+ **setup_kwargs
+ ) as filename, tempdir() as install_dir:
+ _check_wheel_install(filename, install_dir,
+ install_tree, project_name,
+ version, None)
+ w = Wheel(filename)
+ base = pathlib.Path(install_dir) / w.egg_name()
+ script_sh = base / "EGG-INFO" / "scripts" / "script.sh"
+ assert script_sh.exists()
+ if sys.platform != 'win32':
+ # Editable file mode has no effect on Windows
+ assert oct(stat.S_IMODE(script_sh.stat().st_mode)) == "0o777"
from setuptools.extern.packaging.tags import sys_tags
from setuptools.extern.packaging.utils import canonicalize_name
from setuptools.command.egg_info import write_requirements
+from setuptools.archive_util import _unpack_zipfile_obj
WHEEL_NAME = re.compile(
raise ValueError(
'unsupported wheel format version: %s' % wheel_version)
# Extract to target directory.
- os.mkdir(destination_eggdir)
- zf.extractall(destination_eggdir)
+ _unpack_zipfile_obj(zf, destination_eggdir)
# Convert metadata.
dist_info = os.path.join(destination_eggdir, dist_info)
dist = pkg_resources.Distribution.from_location(