codekingpro/portable-devtools
114k
1# https://github.com/python/mypy/issues/169362# mypy: disable-error-code="has-type"3"""Integration tests for setuptools that focus on building packages via pip.4 5The idea behind these tests is not to exhaustively check all the possible6combinations of packages, operating systems, supporting libraries, etc, but7rather check a limited number of popular packages and how they interact with8the exposed public API. This way if any change in API is introduced, we hope to9identify backward compatibility problems before publishing a release.10 11The number of tested packages is purposefully kept small, to minimise duration12and the associated maintenance cost (changes in the way these packages define13their build process may require changes in the tests).14"""15 16import json17import os18import shutil19import sys20from enum import Enum21from glob import glob22from hashlib import md523from urllib.request import urlopen24 25import pytest26from packaging.requirements import Requirement27 28from .helpers import Archive, run29 30pytestmark = pytest.mark.integration31 32 33(LATEST,) = Enum("v", "LATEST") # type: ignore[misc] # https://github.com/python/mypy/issues/1693634"""Default version to be checked"""35# There are positive and negative aspects of checking the latest version of the36# packages.37# The main positive aspect is that the latest version might have already38# removed the use of APIs deprecated in previous releases of setuptools.39 40 41# Packages to be tested:42# (Please notice the test environment cannot support EVERY library required for43# compiling binary extensions. In Ubuntu/Debian nomenclature, we only assume44# that `build-essential`, `gfortran` and `libopenblas-dev` are installed,45# due to their relevance to the numerical/scientific programming ecosystem)46EXAMPLES = [47 ("pip", LATEST), # just in case...48 ("pytest", LATEST), # uses setuptools_scm49 ("mypy", LATEST), # custom build_py + ext_modules50 # --- Popular packages: https://hugovk.github.io/top-pypi-packages/ ---51 ("botocore", LATEST),52 ("kiwisolver", LATEST), # build_ext53 ("brotli", LATEST), # not in the list but used by urllib354 ("pyyaml", LATEST), # cython + custom build_ext + custom distclass55 ("charset-normalizer", LATEST), # uses mypyc, used by aiohttp56 ("protobuf", LATEST),57 # ("requests", LATEST), # XXX: https://github.com/psf/requests/pull/692058 ("celery", LATEST),59 # When adding packages to this list, make sure they expose a `__version__`60 # attribute, or modify the tests below61]62 63 64# Some packages have "optional" dependencies that modify their build behaviour65# and are not listed in pyproject.toml, others still use `setup_requires`66EXTRA_BUILD_DEPS = {67 "pyyaml": ("Cython<3.0",), # constraint to avoid errors68 "charset-normalizer": ("mypy>=1.4.1",), # no pyproject.toml available69}70 71EXTRA_ENV_VARS = {72 "pyyaml": {"PYYAML_FORCE_CYTHON": "1"},73 "charset-normalizer": {"CHARSET_NORMALIZER_USE_MYPYC": "1"},74}75 76IMPORT_NAME = {77 "pyyaml": "yaml",78 "protobuf": "google.protobuf",79}80 81 82VIRTUALENV = (sys.executable, "-m", "virtualenv")83 84 85# By default, pip will try to build packages in isolation (PEP 517), which86# means it will download the previous stable version of setuptools.87# `pip` flags can avoid that (the version of setuptools under test88# should be the one to be used)89INSTALL_OPTIONS = (90 "--ignore-installed",91 "--no-build-isolation",92 # Omit "--no-binary :all:" the sdist is supplied directly.93 # Allows dependencies as wheels.94)95# The downside of `--no-build-isolation` is that pip will not download build96# dependencies. The test script will have to also handle that.97 98 99@pytest.fixture100def venv_python(tmp_path):101 run([*VIRTUALENV, str(tmp_path / ".venv")])102 possible_path = (str(p.parent) for p in tmp_path.glob(".venv/*/python*"))103 return shutil.which("python", path=os.pathsep.join(possible_path))104 105 106@pytest.fixture(autouse=True)107def _prepare(tmp_path, venv_python, monkeypatch):108 download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path))109 os.makedirs(download_path, exist_ok=True)110 111 # Environment vars used for building some of the packages112 monkeypatch.setenv("USE_MYPYC", "1")113 114 yield115 116 # Let's provide the maximum amount of information possible in the case117 # it is necessary to debug the tests directly from the CI logs.118 print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")119 print("Temporary directory:")120 map(print, tmp_path.glob("*"))121 print("Virtual environment:")122 run([venv_python, "-m", "pip", "freeze"])123 124 125@pytest.mark.parametrize(("package", "version"), EXAMPLES)126@pytest.mark.uses_network127def test_install_sdist(package, version, tmp_path, venv_python, setuptools_wheel):128 venv_pip = (venv_python, "-m", "pip")129 sdist = retrieve_sdist(package, version, tmp_path)130 deps = build_deps(package, sdist)131 if deps:132 print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")133 print("Dependencies:", deps)134 run([*venv_pip, "install", *deps])135 136 # Use a virtualenv to simulate PEP 517 isolation137 # but install fresh setuptools wheel to ensure the version under development138 env = EXTRA_ENV_VARS.get(package, {})139 run([*venv_pip, "install", "--force-reinstall", setuptools_wheel])140 run([*venv_pip, "install", *INSTALL_OPTIONS, sdist], env)141 142 # Execute a simple script to make sure the package was installed correctly143 pkg = IMPORT_NAME.get(package, package).replace("-", "_")144 script = f"import {pkg}; print(getattr({pkg}, '__version__', 0))"145 run([venv_python, "-c", script])146 147 148# ---- Helper Functions ----149 150 151def retrieve_sdist(package, version, tmp_path):152 """Either use cached sdist file or download it from PyPI"""153 # `pip download` cannot be used due to154 # https://github.com/pypa/pip/issues/1884155 # https://discuss.python.org/t/pep-625-file-name-of-a-source-distribution/4686156 # We have to find the correct distribution file and download it157 download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path))158 dist = retrieve_pypi_sdist_metadata(package, version)159 160 # Remove old files to prevent cache to grow indefinitely161 for file in glob(os.path.join(download_path, f"{package}*")):162 if dist["filename"] != file:163 os.unlink(file)164 165 dist_file = os.path.join(download_path, dist["filename"])166 if not os.path.exists(dist_file):167 download(dist["url"], dist_file, dist["md5_digest"])168 return dist_file169 170 171def retrieve_pypi_sdist_metadata(package, version):172 # https://warehouse.pypa.io/api-reference/json.html173 id_ = package if version is LATEST else f"{package}/{version}"174 with urlopen(f"https://pypi.org/pypi/{id_}/json") as f:175 metadata = json.load(f)176 177 if metadata["info"]["yanked"]:178 raise ValueError(f"Release for {package} {version} was yanked")179 180 version = metadata["info"]["version"]181 release = metadata["releases"][version] if version is LATEST else metadata["urls"]182 (sdist,) = filter(lambda d: d["packagetype"] == "sdist", release)183 return sdist184 185 186def download(url, dest, md5_digest):187 with urlopen(url) as f:188 data = f.read()189 190 assert md5(data).hexdigest() == md5_digest191 192 with open(dest, "wb") as f:193 f.write(data)194 195 assert os.path.exists(dest)196 197 198def build_deps(package, sdist_file):199 """Find out what are the build dependencies for a package.200 201 "Manually" install them, since pip will not install build202 deps with `--no-build-isolation`.203 """204 # delay importing, since pytest discovery phase may hit this file from a205 # testenv without tomli206 from setuptools.compat.py310 import tomllib207 208 archive = Archive(sdist_file)209 info = tomllib.loads(_read_pyproject(archive))210 deps = info.get("build-system", {}).get("requires", [])211 deps += EXTRA_BUILD_DEPS.get(package, [])212 # Remove setuptools from requirements (and deduplicate)213 requirements = {Requirement(d).name: d for d in deps}214 return [v for k, v in requirements.items() if k != "setuptools"]215 216 217def _read_pyproject(archive):218 contents = (219 archive.get_content(member)220 for member in archive221 if os.path.basename(archive.get_name(member)) == "pyproject.toml"222 )223 return next(contents, "")224 