1"""Integration tests for setuptools that focus on building packages via pip. 2 3The idea behind these tests is not to exhaustively check all the possible 4combinations of packages, operating systems, supporting libraries, etc, but 5rather check a limited number of popular packages and how they interact with 6the exposed public API. This way if any change in API is introduced, we hope to 7identify backward compatibility problems before publishing a release. 8 9The number of tested packages is purposefully kept small, to minimise duration 10and the associated maintenance cost (changes in the way these packages define 11their build process may require changes in the tests). 12""" 13import json 14import os 15import shutil 16import sys 17from enum import Enum 18from glob import glob 19from hashlib import md5 20from urllib.request import urlopen 21 22import pytest 23from packaging.requirements import Requirement 24 25from .helpers import Archive, run 26 27 28pytestmark = pytest.mark.integration 29 30LATEST, = list(Enum("v", "LATEST")) 31"""Default version to be checked""" 32# There are positive and negative aspects of checking the latest version of the 33# packages. 34# The main positive aspect is that the latest version might have already 35# removed the use of APIs deprecated in previous releases of setuptools. 36 37 38# Packages to be tested: 39# (Please notice the test environment cannot support EVERY library required for 40# compiling binary extensions. In Ubuntu/Debian nomenclature, we only assume 41# that `build-essential`, `gfortran` and `libopenblas-dev` are installed, 42# due to their relevance to the numerical/scientific programming ecosystem) 43EXAMPLES = [ 44 ("pandas", LATEST), # cython + custom build_ext 45 ("sphinx", LATEST), # custom setup.py 46 ("pip", LATEST), # just in case... 47 ("pytest", LATEST), # uses setuptools_scm 48 ("mypy", LATEST), # custom build_py + ext_modules 49 50 # --- Popular packages: https://hugovk.github.io/top-pypi-packages/ --- 51 ("botocore", LATEST), 52 ("kiwisolver", "1.3.2"), # build_ext, version pinned due to setup_requires 53 ("brotli", LATEST), # not in the list but used by urllib3 54 55 # When adding packages to this list, make sure they expose a `__version__` 56 # attribute, or modify the tests below 57] 58 59 60# Some packages have "optional" dependencies that modify their build behaviour 61# and are not listed in pyproject.toml, others still use `setup_requires` 62EXTRA_BUILD_DEPS = { 63 "sphinx": ("babel>=1.3",), 64 "kiwisolver": ("cppy>=1.1.0",) 65} 66 67 68VIRTUALENV = (sys.executable, "-m", "virtualenv") 69 70 71# By default, pip will try to build packages in isolation (PEP 517), which 72# means it will download the previous stable version of setuptools. 73# `pip` flags can avoid that (the version of setuptools under test 74# should be the one to be used) 75SDIST_OPTIONS = ( 76 "--ignore-installed", 77 "--no-build-isolation", 78 # We don't need "--no-binary :all:" since we specify the path to the sdist. 79 # It also helps with performance, since dependencies can come from wheels. 80) 81# The downside of `--no-build-isolation` is that pip will not download build 82# dependencies. The test script will have to also handle that. 83 84 85@pytest.fixture 86def venv_python(tmp_path): 87 run([*VIRTUALENV, str(tmp_path / ".venv")]) 88 possible_path = (str(p.parent) for p in tmp_path.glob(".venv/*/python*")) 89 return shutil.which("python", path=os.pathsep.join(possible_path)) 90 91 92@pytest.fixture(autouse=True) 93def _prepare(tmp_path, venv_python, monkeypatch, request): 94 download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path)) 95 os.makedirs(download_path, exist_ok=True) 96 97 # Environment vars used for building some of the packages 98 monkeypatch.setenv("USE_MYPYC", "1") 99 100 def _debug_info(): 101 # Let's provide the maximum amount of information possible in the case 102 # it is necessary to debug the tests directly from the CI logs. 103 print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") 104 print("Temporary directory:") 105 map(print, tmp_path.glob("*")) 106 print("Virtual environment:") 107 run([venv_python, "-m", "pip", "freeze"]) 108 request.addfinalizer(_debug_info) 109 110 111ALREADY_LOADED = ("pytest", "mypy") # loaded by pytest/pytest-enabler 112 113 114@pytest.mark.parametrize('package, version', EXAMPLES) 115@pytest.mark.uses_network 116def test_install_sdist(package, version, tmp_path, venv_python, setuptools_wheel): 117 venv_pip = (venv_python, "-m", "pip") 118 sdist = retrieve_sdist(package, version, tmp_path) 119 deps = build_deps(package, sdist) 120 if deps: 121 print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") 122 print("Dependencies:", deps) 123 run([*venv_pip, "install", *deps]) 124 125 # Use a virtualenv to simulate PEP 517 isolation 126 # but install fresh setuptools wheel to ensure the version under development 127 run([*venv_pip, "install", "-I", setuptools_wheel]) 128 run([*venv_pip, "install", *SDIST_OPTIONS, sdist]) 129 130 # Execute a simple script to make sure the package was installed correctly 131 script = f"import {package}; print(getattr({package}, '__version__', 0))" 132 run([venv_python, "-c", script]) 133 134 135# ---- Helper Functions ---- 136 137 138def retrieve_sdist(package, version, tmp_path): 139 """Either use cached sdist file or download it from PyPI""" 140 # `pip download` cannot be used due to 141 # https://github.com/pypa/pip/issues/1884 142 # https://discuss.python.org/t/pep-625-file-name-of-a-source-distribution/4686 143 # We have to find the correct distribution file and download it 144 download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path)) 145 dist = retrieve_pypi_sdist_metadata(package, version) 146 147 # Remove old files to prevent cache to grow indefinitely 148 for file in glob(os.path.join(download_path, f"{package}*")): 149 if dist["filename"] != file: 150 os.unlink(file) 151 152 dist_file = os.path.join(download_path, dist["filename"]) 153 if not os.path.exists(dist_file): 154 download(dist["url"], dist_file, dist["md5_digest"]) 155 return dist_file 156 157 158def retrieve_pypi_sdist_metadata(package, version): 159 # https://warehouse.pypa.io/api-reference/json.html 160 id_ = package if version is LATEST else f"{package}/{version}" 161 with urlopen(f"https://pypi.org/pypi/{id_}/json") as f: 162 metadata = json.load(f) 163 164 if metadata["info"]["yanked"]: 165 raise ValueError(f"Release for {package} {version} was yanked") 166 167 version = metadata["info"]["version"] 168 release = metadata["releases"][version] 169 dists = [d for d in release if d["packagetype"] == "sdist"] 170 if len(dists) == 0: 171 raise ValueError(f"No sdist found for {package} {version}") 172 173 for dist in dists: 174 if dist["filename"].endswith(".tar.gz"): 175 return dist 176 177 # Not all packages are publishing tar.gz 178 return dist 179 180 181def download(url, dest, md5_digest): 182 with urlopen(url) as f: 183 data = f.read() 184 185 assert md5(data).hexdigest() == md5_digest 186 187 with open(dest, "wb") as f: 188 f.write(data) 189 190 assert os.path.exists(dest) 191 192 193def build_deps(package, sdist_file): 194 """Find out what are the build dependencies for a package. 195 196 We need to "manually" install them, since pip will not install build 197 deps with `--no-build-isolation`. 198 """ 199 import tomli as toml 200 201 # delay importing, since pytest discovery phase may hit this file from a 202 # testenv without tomli 203 204 archive = Archive(sdist_file) 205 pyproject = _read_pyproject(archive) 206 207 info = toml.loads(pyproject) 208 deps = info.get("build-system", {}).get("requires", []) 209 deps += EXTRA_BUILD_DEPS.get(package, []) 210 # Remove setuptools from requirements (and deduplicate) 211 requirements = {Requirement(d).name: d for d in deps} 212 return [v for k, v in requirements.items() if k != "setuptools"] 213 214 215def _read_pyproject(archive): 216 for member in archive: 217 if os.path.basename(archive.get_name(member)) == "pyproject.toml": 218 return archive.get_content(member) 219 return "" 220