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