test_pip_install_sdist.py 8.1 KB

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