diff options
-rw-r--r-- | docs/setuptools.txt | 17 | ||||
-rw-r--r-- | setuptools/dist.py | 28 | ||||
-rw-r--r-- | setuptools/installer.py | 129 | ||||
-rw-r--r-- | setuptools/tests/server.py | 19 | ||||
-rw-r--r-- | setuptools/tests/test_easy_install.py | 197 | ||||
-rw-r--r-- | setuptools/tests/test_virtualenv.py | 18 | ||||
-rw-r--r-- | tests/requirements.txt | 1 |
7 files changed, 336 insertions, 73 deletions
diff --git a/docs/setuptools.txt b/docs/setuptools.txt index 399a56d3..9c8821dc 100644 --- a/docs/setuptools.txt +++ b/docs/setuptools.txt @@ -282,10 +282,11 @@ unless you need the associated ``setuptools`` feature. ``setup_requires`` A string or list of strings specifying what other distributions need to be present in order for the *setup script* to run. ``setuptools`` will - attempt to obtain these before processing the rest of the setup script or - commands. This argument is needed if you are using distutils extensions as - part of your build process; for example, extensions that process setup() - arguments and turn them into EGG-INFO metadata files. + attempt to obtain these (using pip if available) before processing the + rest of the setup script or commands. This argument is needed if you + are using distutils extensions as part of your build process; for + example, extensions that process setup() arguments and turn them into + EGG-INFO metadata files. (Note: projects listed in ``setup_requires`` will NOT be automatically installed on the system where the setup script is being run. They are @@ -332,10 +333,10 @@ unless you need the associated ``setuptools`` feature. needed to install it, you can use this option to specify them. It should be a string or list of strings specifying what other distributions need to be present for the package's tests to run. When you run the ``test`` - command, ``setuptools`` will attempt to obtain these. Note that these - required projects will *not* be installed on the system where the tests - are run, but only downloaded to the project's setup directory if they're - not already installed locally. + command, ``setuptools`` will attempt to obtain these (using pip if + available). Note that these required projects will *not* be installed on + the system where the tests are run, but only downloaded to the project's setup + directory if they're not already installed locally. New in 41.5.0: Deprecated the test command. diff --git a/setuptools/dist.py b/setuptools/dist.py index 2e5ad4bd..4a76b52b 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -759,32 +759,8 @@ class Distribution(_Distribution): def fetch_build_egg(self, req): """Fetch an egg needed for building""" - from setuptools.command.easy_install import easy_install - dist = self.__class__({'script_args': ['easy_install']}) - opts = dist.get_option_dict('easy_install') - opts.clear() - opts.update( - (k, v) - for k, v in self.get_option_dict('easy_install').items() - if k in ( - # don't use any other settings - 'find_links', 'site_dirs', 'index_url', - 'optimize', 'site_dirs', 'allow_hosts', - )) - if self.dependency_links: - links = self.dependency_links[:] - if 'find_links' in opts: - links = opts['find_links'][1] + links - opts['find_links'] = ('setup', links) - install_dir = self.get_egg_cache_dir() - cmd = easy_install( - dist, args=["x"], install_dir=install_dir, - exclude_scripts=True, - always_copy=False, build_directory=None, editable=False, - upgrade=False, multi_version=True, no_report=True, user=False - ) - cmd.ensure_finalized() - return cmd.easy_install(req) + from setuptools.installer import fetch_build_egg + return fetch_build_egg(self, req) def _set_global_opts_from_features(self): """Add --with-X/--without-X options based on optional features""" diff --git a/setuptools/installer.py b/setuptools/installer.py new file mode 100644 index 00000000..35bc3cc5 --- /dev/null +++ b/setuptools/installer.py @@ -0,0 +1,129 @@ +import glob +import os +import subprocess +import sys +from distutils import log +from distutils.errors import DistutilsError + +import pkg_resources +from setuptools.command.easy_install import easy_install +from setuptools.wheel import Wheel + +from .py31compat import TemporaryDirectory + + +def _legacy_fetch_build_egg(dist, req): + """Fetch an egg needed for building. + + Legacy path using EasyInstall. + """ + tmp_dist = dist.__class__({'script_args': ['easy_install']}) + opts = tmp_dist.get_option_dict('easy_install') + opts.clear() + opts.update( + (k, v) + for k, v in dist.get_option_dict('easy_install').items() + if k in ( + # don't use any other settings + 'find_links', 'site_dirs', 'index_url', + 'optimize', 'site_dirs', 'allow_hosts', + )) + if dist.dependency_links: + links = dist.dependency_links[:] + if 'find_links' in opts: + links = opts['find_links'][1] + links + opts['find_links'] = ('setup', links) + install_dir = dist.get_egg_cache_dir() + cmd = easy_install( + tmp_dist, args=["x"], install_dir=install_dir, + exclude_scripts=True, + always_copy=False, build_directory=None, editable=False, + upgrade=False, multi_version=True, no_report=True, user=False + ) + cmd.ensure_finalized() + return cmd.easy_install(req) + + +def fetch_build_egg(dist, req): + """Fetch an egg needed for building. + + Use pip/wheel to fetch/build a wheel.""" + # Check pip is available. + try: + pkg_resources.get_distribution('pip') + except pkg_resources.DistributionNotFound: + dist.announce( + 'WARNING: The pip package is not available, falling back ' + 'to EasyInstall for handling setup_requires/test_requires; ' + 'this is deprecated and will be removed in a future version.' + , log.WARN + ) + return _legacy_fetch_build_egg(dist, req) + # Warn if wheel is not. + try: + pkg_resources.get_distribution('wheel') + except pkg_resources.DistributionNotFound: + dist.announce('WARNING: The wheel package is not available.', log.WARN) + if not isinstance(req, pkg_resources.Requirement): + req = pkg_resources.Requirement.parse(req) + # Take easy_install options into account, but do not override relevant + # pip environment variables (like PIP_INDEX_URL or PIP_QUIET); they'll + # take precedence. + opts = dist.get_option_dict('easy_install') + if 'allow_hosts' in opts: + raise DistutilsError('the `allow-hosts` option is not supported ' + 'when using pip to install requirements.') + if 'PIP_QUIET' in os.environ or 'PIP_VERBOSE' in os.environ: + quiet = False + else: + quiet = True + if 'PIP_INDEX_URL' in os.environ: + index_url = None + elif 'index_url' in opts: + index_url = opts['index_url'][1] + else: + index_url = None + if 'find_links' in opts: + find_links = opts['find_links'][1][:] + else: + find_links = [] + if dist.dependency_links: + find_links.extend(dist.dependency_links) + eggs_dir = os.path.realpath(dist.get_egg_cache_dir()) + environment = pkg_resources.Environment() + for egg_dist in pkg_resources.find_distributions(eggs_dir): + if egg_dist in req and environment.can_add(egg_dist): + return egg_dist + with TemporaryDirectory() as tmpdir: + cmd = [ + sys.executable, '-m', 'pip', + '--disable-pip-version-check', + 'wheel', '--no-deps', + '-w', tmpdir, + ] + if quiet: + cmd.append('--quiet') + if index_url is not None: + cmd.extend(('--index-url', index_url)) + if find_links is not None: + for link in find_links: + cmd.extend(('--find-links', link)) + # If requirement is a PEP 508 direct URL, directly pass + # the URL to pip, as `req @ url` does not work on the + # command line. + if req.url: + cmd.append(req.url) + else: + cmd.append(str(req)) + try: + subprocess.check_call(cmd) + except subprocess.CalledProcessError as e: + raise DistutilsError(str(e)) + wheel = Wheel(glob.glob(os.path.join(tmpdir, '*.whl'))[0]) + dist_location = os.path.join(eggs_dir, wheel.egg_name()) + wheel.install_as_egg(dist_location) + dist_metadata = pkg_resources.PathMetadata( + dist_location, os.path.join(dist_location, 'EGG-INFO')) + dist = pkg_resources.Distribution.from_filename( + dist_location, metadata=dist_metadata) + return dist diff --git a/setuptools/tests/server.py b/setuptools/tests/server.py index fc3a5975..8b17b081 100644 --- a/setuptools/tests/server.py +++ b/setuptools/tests/server.py @@ -1,10 +1,13 @@ """Basic http server for tests to simulate PyPI or custom indexes """ +import os import time import threading from setuptools.extern.six.moves import BaseHTTPServer, SimpleHTTPServer +from setuptools.extern.six.moves.urllib_parse import urljoin +from setuptools.extern.six.moves.urllib.request import pathname2url class IndexServer(BaseHTTPServer.HTTPServer): @@ -70,5 +73,19 @@ class MockServer(BaseHTTPServer.HTTPServer, threading.Thread): self.serve_forever() @property + def netloc(self): + return 'localhost:%s' % self.server_port + + @property def url(self): - return 'http://localhost:%(server_port)s/' % vars(self) + return 'http://%s/' % self.netloc + + +def path_to_url(path, authority=None): + """ Convert a path to a file: URL. """ + path = os.path.normpath(os.path.abspath(path)) + base = 'file:' + if authority is not None: + base += '//' + authority + url = urljoin(base, pathname2url(path)) + return url diff --git a/setuptools/tests/test_easy_install.py b/setuptools/tests/test_easy_install.py index c3fd1c6e..aa75899a 100644 --- a/setuptools/tests/test_easy_install.py +++ b/setuptools/tests/test_easy_install.py @@ -15,24 +15,24 @@ import distutils.errors import io import zipfile import mock -from setuptools.command.easy_install import ( - EasyInstallDeprecationWarning, ScriptWriter, WindowsScriptWriter, -) import time + from setuptools.extern import six -from setuptools.extern.six.moves import urllib import pytest from setuptools import sandbox from setuptools.sandbox import run_setup import setuptools.command.easy_install as ei -from setuptools.command.easy_install import PthDistributions +from setuptools.command.easy_install import ( + EasyInstallDeprecationWarning, ScriptWriter, PthDistributions, + WindowsScriptWriter, +) from setuptools.command import easy_install as easy_install_pkg from setuptools.dist import Distribution from pkg_resources import normalize_path, working_set from pkg_resources import Distribution as PRDistribution -import setuptools.tests.server +from setuptools.tests.server import MockServer, path_to_url from setuptools.tests import fail_on_ascii import pkg_resources @@ -440,35 +440,40 @@ def distutils_package(): yield +@pytest.fixture +def mock_index(): + # set up a server which will simulate an alternate package index. + p_index = MockServer() + if p_index.server_port == 0: + # Some platforms (Jython) don't find a port to which to bind, + # so skip test for them. + pytest.skip("could not find a valid port") + p_index.start() + return p_index + + class TestDistutilsPackage: def test_bdist_egg_available_on_distutils_pkg(self, distutils_package): run_setup('setup.py', ['bdist_egg']) class TestSetupRequires: - def test_setup_requires_honors_fetch_params(self): + + def test_setup_requires_honors_fetch_params(self, mock_index, monkeypatch): """ When easy_install installs a source distribution which specifies setup_requires, it should honor the fetch parameters (such as - allow-hosts, index-url, and find-links). + index-url, and find-links). """ - # set up a server which will simulate an alternate package index. - p_index = setuptools.tests.server.MockServer() - p_index.start() - netloc = 1 - p_index_loc = urllib.parse.urlparse(p_index.url)[netloc] - if p_index_loc.endswith(':0'): - # Some platforms (Jython) don't find a port to which to bind, - # so skip this test for them. - return + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) with contexts.quiet(): # create an sdist that has a build-time dependency. with TestSetupRequires.create_sdist() as dist_file: with contexts.tempdir() as temp_install_dir: with contexts.environment(PYTHONPATH=temp_install_dir): ei_params = [ - '--index-url', p_index.url, - '--allow-hosts', p_index_loc, + '--index-url', mock_index.url, '--exclude-scripts', '--install-dir', temp_install_dir, dist_file, @@ -478,10 +483,8 @@ class TestSetupRequires: # fail because it doesn't exist. with pytest.raises(SystemExit): easy_install_pkg.main(ei_params) - # there should have been two or three requests to the server - # (three happens on Python 3.3a) - assert 2 <= len(p_index.requests) <= 3 - assert p_index.requests[0].path == '/does-not-exist/' + # there should have been one requests to the server + assert [r.path for r in mock_index.requests] == ['/does-not-exist/'] @staticmethod @contextlib.contextmanager @@ -500,7 +503,9 @@ class TestSetupRequires: version="1.0", setup_requires = ['does-not-exist'], ) - """))]) + """)), + ('setup.cfg', ''), + ]) yield dist_path use_setup_cfg = ( @@ -632,6 +637,113 @@ class TestSetupRequires: assert len(lines) > 0 assert lines[-1].strip() == '42' + def test_setup_requires_honors_pip_env(self, mock_index, monkeypatch): + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + monkeypatch.setenv(str('PIP_INDEX_URL'), mock_index.url) + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + test_pkg = create_setup_requires_package( + temp_dir, 'python-xlib', '0.19', + setup_attrs=dict(dependency_links=[])) + test_setup_cfg = os.path.join(test_pkg, 'setup.cfg') + with open(test_setup_cfg, 'w') as fp: + fp.write(DALS( + ''' + [easy_install] + index_url = https://pypi.org/legacy/ + ''')) + test_setup_py = os.path.join(test_pkg, 'setup.py') + with pytest.raises(distutils.errors.DistutilsError): + run_setup(test_setup_py, [str('--version')]) + assert len(mock_index.requests) == 1 + assert mock_index.requests[0].path == '/python-xlib/' + + def test_setup_requires_with_pep508_url(self, mock_index, monkeypatch): + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + monkeypatch.setenv(str('PIP_INDEX_URL'), mock_index.url) + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + dep_sdist = os.path.join(temp_dir, 'dep.tar.gz') + make_trivial_sdist(dep_sdist, 'dependency', '42') + dep_url = path_to_url(dep_sdist, authority='localhost') + test_pkg = create_setup_requires_package( + temp_dir, + 'python-xlib', '0.19', # Ignored (overriden by setup_attrs). + setup_attrs=dict(setup_requires='dependency @ %s' % dep_url)) + test_setup_py = os.path.join(test_pkg, 'setup.py') + run_setup(test_setup_py, [str('--version')]) + assert len(mock_index.requests) == 0 + + def test_setup_requires_with_allow_hosts(self, mock_index): + ''' The `allow-hosts` option in not supported anymore. ''' + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + test_pkg = os.path.join(temp_dir, 'test_pkg') + test_setup_py = os.path.join(test_pkg, 'setup.py') + test_setup_cfg = os.path.join(test_pkg, 'setup.cfg') + os.mkdir(test_pkg) + with open(test_setup_py, 'w') as fp: + fp.write(DALS( + ''' + from setuptools import setup + setup(setup_requires='python-xlib') + ''')) + with open(test_setup_cfg, 'w') as fp: + fp.write(DALS( + ''' + [easy_install] + allow_hosts = * + ''')) + with pytest.raises(distutils.errors.DistutilsError): + run_setup(test_setup_py, [str('--version')]) + assert len(mock_index.requests) == 0 + + def test_setup_requires_with_python_requires(self, monkeypatch, tmpdir): + ''' Check `python_requires` is honored. ''' + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + monkeypatch.setenv(str('PIP_NO_INDEX'), str('1')) + monkeypatch.setenv(str('PIP_VERBOSE'), str('1')) + dep_1_0_sdist = 'dep-1.0.tar.gz' + dep_1_0_url = path_to_url(str(tmpdir / dep_1_0_sdist)) + dep_1_0_python_requires = '>=2.7' + make_python_requires_sdist(str(tmpdir / dep_1_0_sdist), 'dep', '1.0', dep_1_0_python_requires) + dep_2_0_sdist = 'dep-2.0.tar.gz' + dep_2_0_url = path_to_url(str(tmpdir / dep_2_0_sdist)) + dep_2_0_python_requires = '!=' + '.'.join(map(str, sys.version_info[:2])) + '.*' + make_python_requires_sdist(str(tmpdir / dep_2_0_sdist), 'dep', '2.0', dep_2_0_python_requires) + index = tmpdir / 'index.html' + index.write_text(DALS( + ''' + <!DOCTYPE html> + <html><head><title>Links for dep</title></head> + <body> + <h1>Links for dep</h1> + <a href="{dep_1_0_url}" data-requires-python="{dep_1_0_python_requires}">{dep_1_0_sdist}</a><br/> + <a href="{dep_2_0_url}" data-requires-python="{dep_2_0_python_requires}">{dep_2_0_sdist}</a><br/> + </body> + </html> + ''').format( + dep_1_0_url=dep_1_0_url, + dep_1_0_sdist=dep_1_0_sdist, + dep_1_0_python_requires=dep_1_0_python_requires, + dep_2_0_url=dep_2_0_url, + dep_2_0_sdist=dep_2_0_sdist, + dep_2_0_python_requires=dep_2_0_python_requires, + ), 'utf-8') + index_url = path_to_url(str(index)) + with contexts.save_pkg_resources_state(): + test_pkg = create_setup_requires_package( + str(tmpdir), + 'python-xlib', '0.19', # Ignored (overriden by setup_attrs). + setup_attrs=dict(setup_requires='dep', dependency_links=[index_url])) + test_setup_py = os.path.join(test_pkg, 'setup.py') + run_setup(test_setup_py, [str('--version')]) + eggs = list(map(str, pkg_resources.find_distributions(os.path.join(test_pkg, '.eggs')))) + assert eggs == ['dep 1.0'] + def make_trivial_sdist(dist_path, distname, version): """ @@ -647,7 +759,9 @@ def make_trivial_sdist(dist_path, distname, version): name=%r, version=%r ) - """ % (distname, version)))]) + """ % (distname, version))), + ('setup.cfg', ''), + ]) def make_nspkg_sdist(dist_path, distname, version): @@ -683,12 +797,29 @@ def make_nspkg_sdist(dist_path, distname, version): make_sdist(dist_path, files) +def make_python_requires_sdist(dist_path, distname, version, python_requires): + make_sdist(dist_path, [ + ('setup.py', DALS("""\ + import setuptools + setuptools.setup( + name={name!r}, + version={version!r}, + python_requires={python_requires!r}, + ) + """).format(name=distname, version=version, + python_requires=python_requires)), + ('setup.cfg', ''), + ]) + + def make_sdist(dist_path, files): """ Create a simple sdist tarball at dist_path, containing the files listed in ``files`` as ``(filename, content)`` tuples. """ + # Distributions with only one file don't play well with pip. + assert len(files) > 1 with tarfile.open(dist_path, 'w:gz') as dist: for filename, content in files: file_bytes = io.BytesIO(content.encode('utf-8')) @@ -721,8 +852,8 @@ def create_setup_requires_package(path, distname='foobar', version='0.1', test_pkg = os.path.join(path, 'test_pkg') os.mkdir(test_pkg) + # setup.cfg if use_setup_cfg: - test_setup_cfg = os.path.join(test_pkg, 'setup.cfg') options = [] metadata = [] for name in use_setup_cfg: @@ -734,8 +865,7 @@ def create_setup_requires_package(path, distname='foobar', version='0.1', if isinstance(value, (tuple, list)): value = ';'.join(value) section.append('%s: %s' % (name, value)) - with open(test_setup_cfg, 'w') as f: - f.write(DALS( + test_setup_cfg_contents = DALS( """ [metadata] {metadata} @@ -745,16 +875,19 @@ def create_setup_requires_package(path, distname='foobar', version='0.1', ).format( options='\n'.join(options), metadata='\n'.join(metadata), - )) - - test_setup_py = os.path.join(test_pkg, 'setup.py') + ) + else: + test_setup_cfg_contents = '' + with open(os.path.join(test_pkg, 'setup.cfg'), 'w') as f: + f.write(test_setup_cfg_contents) + # setup.py if setup_py_template is None: setup_py_template = DALS("""\ import setuptools setuptools.setup(**%r) """) - with open(test_setup_py, 'w') as f: + with open(os.path.join(test_pkg, 'setup.py'), 'w') as f: f.write(setup_py_template % test_setup_attrs) foobar_path = os.path.join(path, '%s-%s.tar.gz' % (distname, version)) diff --git a/setuptools/tests/test_virtualenv.py b/setuptools/tests/test_virtualenv.py index 74a1284c..cd3d9313 100644 --- a/setuptools/tests/test_virtualenv.py +++ b/setuptools/tests/test_virtualenv.py @@ -121,14 +121,12 @@ def test_pip_upgrade_from_source(pip_version, virtualenv): virtualenv.run('pip install --no-cache-dir --upgrade ' + sdist) -def test_test_command_install_requirements(bare_virtualenv, tmpdir): +def _check_test_command_install_requirements(virtualenv, tmpdir): """ Check the test command will install all required dependencies. """ - bare_virtualenv.run(' && '.join(( - 'cd {source}', - 'python setup.py develop', - )).format(source=SOURCE_DIR)) + # Install setuptools. + virtualenv.run('python setup.py develop', cd=SOURCE_DIR) def sdist(distname, version): dist_path = tmpdir.join('%s-%s.tar.gz' % (distname, version)) @@ -179,12 +177,20 @@ def test_test_command_install_requirements(bare_virtualenv, tmpdir): open('success', 'w').close() ''')) # Run test command for test package. - bare_virtualenv.run(' && '.join(( + virtualenv.run(' && '.join(( 'cd {tmpdir}', 'python setup.py test -s test', )).format(tmpdir=tmpdir)) assert tmpdir.join('success').check() +def test_test_command_install_requirements(virtualenv, tmpdir): + # Ensure pip/wheel packages are installed. + virtualenv.run("python -c \"__import__('pkg_resources').require(['pip', 'wheel'])\"") + _check_test_command_install_requirements(virtualenv, tmpdir) + +def test_test_command_install_requirements_when_using_easy_install(bare_virtualenv, tmpdir): + _check_test_command_install_requirements(bare_virtualenv, tmpdir) + def test_no_missing_dependencies(bare_virtualenv): """ diff --git a/tests/requirements.txt b/tests/requirements.txt index 1f70adee..1f8bd19d 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -9,3 +9,4 @@ coverage>=4.5.1 pytest-cov>=2.5.1 paver; python_version>="3.6" futures; python_version=="2.7" +pip>=19.1 # For proper file:// URLs support. |