| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- # https://github.com/python/mypy/issues/16936
- # mypy: disable-error-code="has-type"
- """Integration tests for setuptools that focus on building packages via pip.
- The idea behind these tests is not to exhaustively check all the possible
- combinations of packages, operating systems, supporting libraries, etc, but
- rather check a limited number of popular packages and how they interact with
- the exposed public API. This way if any change in API is introduced, we hope to
- identify backward compatibility problems before publishing a release.
- The number of tested packages is purposefully kept small, to minimise duration
- and the associated maintenance cost (changes in the way these packages define
- their build process may require changes in the tests).
- """
- import json
- import os
- import shutil
- import sys
- from enum import Enum
- from glob import glob
- from hashlib import md5
- from urllib.request import urlopen
- import pytest
- from packaging.requirements import Requirement
- from .helpers import Archive, run
- pytestmark = pytest.mark.integration
- (LATEST,) = Enum("v", "LATEST") # type: ignore[misc] # https://github.com/python/mypy/issues/16936
- """Default version to be checked"""
- # There are positive and negative aspects of checking the latest version of the
- # packages.
- # The main positive aspect is that the latest version might have already
- # removed the use of APIs deprecated in previous releases of setuptools.
- # Packages to be tested:
- # (Please notice the test environment cannot support EVERY library required for
- # compiling binary extensions. In Ubuntu/Debian nomenclature, we only assume
- # that `build-essential`, `gfortran` and `libopenblas-dev` are installed,
- # due to their relevance to the numerical/scientific programming ecosystem)
- EXAMPLES = [
- ("pip", LATEST), # just in case...
- ("pytest", LATEST), # uses setuptools_scm
- ("mypy", LATEST), # custom build_py + ext_modules
- # --- Popular packages: https://hugovk.github.io/top-pypi-packages/ ---
- ("botocore", LATEST),
- ("kiwisolver", LATEST), # build_ext
- ("brotli", LATEST), # not in the list but used by urllib3
- ("pyyaml", LATEST), # cython + custom build_ext + custom distclass
- ("charset-normalizer", LATEST), # uses mypyc, used by aiohttp
- ("protobuf", LATEST),
- # ("requests", LATEST), # XXX: https://github.com/psf/requests/pull/6920
- ("celery", LATEST),
- # When adding packages to this list, make sure they expose a `__version__`
- # attribute, or modify the tests below
- ]
- # Some packages have "optional" dependencies that modify their build behaviour
- # and are not listed in pyproject.toml, others still use `setup_requires`
- EXTRA_BUILD_DEPS = {
- "pyyaml": ("Cython<3.0",), # constraint to avoid errors
- "charset-normalizer": ("mypy>=1.4.1",), # no pyproject.toml available
- }
- EXTRA_ENV_VARS = {
- "pyyaml": {"PYYAML_FORCE_CYTHON": "1"},
- "charset-normalizer": {"CHARSET_NORMALIZER_USE_MYPYC": "1"},
- }
- IMPORT_NAME = {
- "pyyaml": "yaml",
- "protobuf": "google.protobuf",
- }
- VIRTUALENV = (sys.executable, "-m", "virtualenv")
- # By default, pip will try to build packages in isolation (PEP 517), which
- # means it will download the previous stable version of setuptools.
- # `pip` flags can avoid that (the version of setuptools under test
- # should be the one to be used)
- INSTALL_OPTIONS = (
- "--ignore-installed",
- "--no-build-isolation",
- # Omit "--no-binary :all:" the sdist is supplied directly.
- # Allows dependencies as wheels.
- )
- # The downside of `--no-build-isolation` is that pip will not download build
- # dependencies. The test script will have to also handle that.
- @pytest.fixture
- def venv_python(tmp_path):
- run([*VIRTUALENV, str(tmp_path / ".venv")])
- possible_path = (str(p.parent) for p in tmp_path.glob(".venv/*/python*"))
- return shutil.which("python", path=os.pathsep.join(possible_path))
- @pytest.fixture(autouse=True)
- def _prepare(tmp_path, venv_python, monkeypatch):
- download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path))
- os.makedirs(download_path, exist_ok=True)
- # Environment vars used for building some of the packages
- monkeypatch.setenv("USE_MYPYC", "1")
- yield
- # Let's provide the maximum amount of information possible in the case
- # it is necessary to debug the tests directly from the CI logs.
- print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
- print("Temporary directory:")
- map(print, tmp_path.glob("*"))
- print("Virtual environment:")
- run([venv_python, "-m", "pip", "freeze"])
- @pytest.mark.parametrize(("package", "version"), EXAMPLES)
- @pytest.mark.uses_network
- def test_install_sdist(package, version, tmp_path, venv_python, setuptools_wheel):
- venv_pip = (venv_python, "-m", "pip")
- sdist = retrieve_sdist(package, version, tmp_path)
- deps = build_deps(package, sdist)
- if deps:
- print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
- print("Dependencies:", deps)
- run([*venv_pip, "install", *deps])
- # Use a virtualenv to simulate PEP 517 isolation
- # but install fresh setuptools wheel to ensure the version under development
- env = EXTRA_ENV_VARS.get(package, {})
- run([*venv_pip, "install", "--force-reinstall", setuptools_wheel])
- run([*venv_pip, "install", *INSTALL_OPTIONS, sdist], env)
- # Execute a simple script to make sure the package was installed correctly
- pkg = IMPORT_NAME.get(package, package).replace("-", "_")
- script = f"import {pkg}; print(getattr({pkg}, '__version__', 0))"
- run([venv_python, "-c", script])
- # ---- Helper Functions ----
- def retrieve_sdist(package, version, tmp_path):
- """Either use cached sdist file or download it from PyPI"""
- # `pip download` cannot be used due to
- # https://github.com/pypa/pip/issues/1884
- # https://discuss.python.org/t/pep-625-file-name-of-a-source-distribution/4686
- # We have to find the correct distribution file and download it
- download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path))
- dist = retrieve_pypi_sdist_metadata(package, version)
- # Remove old files to prevent cache to grow indefinitely
- for file in glob(os.path.join(download_path, f"{package}*")):
- if dist["filename"] != file:
- os.unlink(file)
- dist_file = os.path.join(download_path, dist["filename"])
- if not os.path.exists(dist_file):
- download(dist["url"], dist_file, dist["md5_digest"])
- return dist_file
- def retrieve_pypi_sdist_metadata(package, version):
- # https://warehouse.pypa.io/api-reference/json.html
- id_ = package if version is LATEST else f"{package}/{version}"
- with urlopen(f"https://pypi.org/pypi/{id_}/json") as f:
- metadata = json.load(f)
- if metadata["info"]["yanked"]:
- raise ValueError(f"Release for {package} {version} was yanked")
- version = metadata["info"]["version"]
- release = metadata["releases"][version] if version is LATEST else metadata["urls"]
- (sdist,) = filter(lambda d: d["packagetype"] == "sdist", release)
- return sdist
- def download(url, dest, md5_digest):
- with urlopen(url) as f:
- data = f.read()
- assert md5(data).hexdigest() == md5_digest
- with open(dest, "wb") as f:
- f.write(data)
- assert os.path.exists(dest)
- def build_deps(package, sdist_file):
- """Find out what are the build dependencies for a package.
- "Manually" install them, since pip will not install build
- deps with `--no-build-isolation`.
- """
- # delay importing, since pytest discovery phase may hit this file from a
- # testenv without tomli
- from setuptools.compat.py310 import tomllib
- archive = Archive(sdist_file)
- info = tomllib.loads(_read_pyproject(archive))
- deps = info.get("build-system", {}).get("requires", [])
- deps += EXTRA_BUILD_DEPS.get(package, [])
- # Remove setuptools from requirements (and deduplicate)
- requirements = {Requirement(d).name: d for d in deps}
- return [v for k, v in requirements.items() if k != "setuptools"]
- def _read_pyproject(archive):
- contents = (
- archive.get_content(member)
- for member in archive
- if os.path.basename(archive.get_name(member)) == "pyproject.toml"
- )
- return next(contents, "")
|