1"""Make sure that applying the configuration from pyproject.toml is equivalent to
2applying a similar configuration from setup.cfg
3"""
4import io
5import re
6from pathlib import Path
7from urllib.request import urlopen
8from unittest.mock import Mock
9
10import pytest
11from ini2toml.api import Translator
12
13import setuptools  # noqa ensure monkey patch to metadata
14from setuptools.dist import Distribution
15from setuptools.config import setupcfg, pyprojecttoml
16from setuptools.config import expand
17
18
19EXAMPLES = (Path(__file__).parent / "setupcfg_examples.txt").read_text()
20EXAMPLE_URLS = [x for x in EXAMPLES.splitlines() if not x.startswith("#")]
21DOWNLOAD_DIR = Path(__file__).parent / "downloads"
22
23
24def makedist(path):
25    return Distribution({"src_root": path})
26
27
28@pytest.mark.parametrize("url", EXAMPLE_URLS)
29@pytest.mark.filterwarnings("ignore")
30@pytest.mark.uses_network
31def test_apply_pyproject_equivalent_to_setupcfg(url, monkeypatch, tmp_path):
32    monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.0.1"))
33    setupcfg_example = retrieve_file(url, DOWNLOAD_DIR)
34    pyproject_example = Path(tmp_path, "pyproject.toml")
35    toml_config = Translator().translate(setupcfg_example.read_text(), "setup.cfg")
36    pyproject_example.write_text(toml_config)
37
38    dist_toml = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject_example)
39    dist_cfg = setupcfg.apply_configuration(makedist(tmp_path), setupcfg_example)
40
41    pkg_info_toml = core_metadata(dist_toml)
42    pkg_info_cfg = core_metadata(dist_cfg)
43    assert pkg_info_toml == pkg_info_cfg
44
45    if any(getattr(d, "license_files", None) for d in (dist_toml, dist_cfg)):
46        assert set(dist_toml.license_files) == set(dist_cfg.license_files)
47
48    if any(getattr(d, "entry_points", None) for d in (dist_toml, dist_cfg)):
49        print(dist_cfg.entry_points)
50        ep_toml = {(k, *sorted(i.replace(" ", "") for i in v))
51                   for k, v in dist_toml.entry_points.items()}
52        ep_cfg = {(k, *sorted(i.replace(" ", "") for i in v))
53                  for k, v in dist_cfg.entry_points.items()}
54        assert ep_toml == ep_cfg
55
56    if any(getattr(d, "package_data", None) for d in (dist_toml, dist_cfg)):
57        pkg_data_toml = {(k, *sorted(v)) for k, v in dist_toml.package_data.items()}
58        pkg_data_cfg = {(k, *sorted(v)) for k, v in dist_cfg.package_data.items()}
59        assert pkg_data_toml == pkg_data_cfg
60
61    if any(getattr(d, "data_files", None) for d in (dist_toml, dist_cfg)):
62        data_files_toml = {(k, *sorted(v)) for k, v in dist_toml.data_files}
63        data_files_cfg = {(k, *sorted(v)) for k, v in dist_cfg.data_files}
64        assert data_files_toml == data_files_cfg
65
66    assert set(dist_toml.install_requires) == set(dist_cfg.install_requires)
67    if any(getattr(d, "extras_require", None) for d in (dist_toml, dist_cfg)):
68        if (
69            "testing" in dist_toml.extras_require
70            and "testing" not in dist_cfg.extras_require
71        ):
72            # ini2toml can automatically convert `tests_require` to `testing` extra
73            dist_toml.extras_require.pop("testing")
74        extra_req_toml = {(k, *sorted(v)) for k, v in dist_toml.extras_require.items()}
75        extra_req_cfg = {(k, *sorted(v)) for k, v in dist_cfg.extras_require.items()}
76        assert extra_req_toml == extra_req_cfg
77
78
79PEP621_EXAMPLE = """\
80[project]
81name = "spam"
82version = "2020.0.0"
83description = "Lovely Spam! Wonderful Spam!"
84readme = "README.rst"
85requires-python = ">=3.8"
86license = {file = "LICENSE.txt"}
87keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
88authors = [
89  {email = "[email protected]"},
90  {name = "Tzu-Ping Chung"}
91]
92maintainers = [
93  {name = "Brett Cannon", email = "[email protected]"}
94]
95classifiers = [
96  "Development Status :: 4 - Beta",
97  "Programming Language :: Python"
98]
99
100dependencies = [
101  "httpx",
102  "gidgethub[httpx]>4.0.0",
103  "django>2.1; os_name != 'nt'",
104  "django>2.0; os_name == 'nt'"
105]
106
107[project.optional-dependencies]
108test = [
109  "pytest < 5.0.0",
110  "pytest-cov[all]"
111]
112
113[project.urls]
114homepage = "http://example.com"
115documentation = "http://readthedocs.org"
116repository = "http://github.com"
117changelog = "http://github.com/me/spam/blob/master/CHANGELOG.md"
118
119[project.scripts]
120spam-cli = "spam:main_cli"
121
122[project.gui-scripts]
123spam-gui = "spam:main_gui"
124
125[project.entry-points."spam.magical"]
126tomatoes = "spam:main_tomatoes"
127"""
128
129PEP621_EXAMPLE_SCRIPT = """
130def main_cli(): pass
131def main_gui(): pass
132def main_tomatoes(): pass
133"""
134
135
136def _pep621_example_project(tmp_path, readme="README.rst"):
137    pyproject = tmp_path / "pyproject.toml"
138    text = PEP621_EXAMPLE
139    replacements = {'readme = "README.rst"': f'readme = "{readme}"'}
140    for orig, subst in replacements.items():
141        text = text.replace(orig, subst)
142    pyproject.write_text(text)
143
144    (tmp_path / readme).write_text("hello world")
145    (tmp_path / "LICENSE.txt").write_text("--- LICENSE stub ---")
146    (tmp_path / "spam.py").write_text(PEP621_EXAMPLE_SCRIPT)
147    return pyproject
148
149
150def test_pep621_example(tmp_path):
151    """Make sure the example in PEP 621 works"""
152    pyproject = _pep621_example_project(tmp_path)
153    dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
154    assert dist.metadata.license == "--- LICENSE stub ---"
155    assert set(dist.metadata.license_files) == {"LICENSE.txt"}
156
157
158@pytest.mark.parametrize(
159    "readme, ctype",
160    [
161        ("Readme.txt", "text/plain"),
162        ("readme.md", "text/markdown"),
163        ("text.rst", "text/x-rst"),
164    ]
165)
166def test_readme_content_type(tmp_path, readme, ctype):
167    pyproject = _pep621_example_project(tmp_path, readme)
168    dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
169    assert dist.metadata.long_description_content_type == ctype
170
171
172def test_undefined_content_type(tmp_path):
173    pyproject = _pep621_example_project(tmp_path, "README.tex")
174    with pytest.raises(ValueError, match="Undefined content type for README.tex"):
175        pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
176
177
178def test_no_explicit_content_type_for_missing_extension(tmp_path):
179    pyproject = _pep621_example_project(tmp_path, "README")
180    dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
181    assert dist.metadata.long_description_content_type is None
182
183
184# TODO: After PEP 639 is accepted, we have to move the license-files
185#       to the `project` table instead of `tool.setuptools`
186def test_license_and_license_files(tmp_path):
187    pyproject = _pep621_example_project(tmp_path, "README")
188    text = pyproject.read_text(encoding="utf-8")
189
190    # Sanity-check
191    assert 'license = {file = "LICENSE.txt"}' in text
192    assert "[tool.setuptools]" not in text
193
194    text += '\n[tool.setuptools]\nlicense-files = ["_FILE*"]\n'
195    pyproject.write_text(text, encoding="utf-8")
196    (tmp_path / "_FILE.txt").touch()
197    (tmp_path / "_FILE.rst").touch()
198
199    # Would normally match the `license_files` glob patterns, but we want to exclude it
200    # by being explicit. On the other hand, its contents should be added to `license`
201    (tmp_path / "LICENSE.txt").write_text("LicenseRef-Proprietary\n", encoding="utf-8")
202
203    dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
204    assert set(dist.metadata.license_files) == {"_FILE.rst", "_FILE.txt"}
205    assert dist.metadata.license == "LicenseRef-Proprietary\n"
206
207
208# --- Auxiliary Functions ---
209
210
211NAME_REMOVE = ("http://", "https://", "github.com/", "/raw/")
212
213
214def retrieve_file(url, download_dir):
215    file_name = url.strip()
216    for part in NAME_REMOVE:
217        file_name = file_name.replace(part, '').strip().strip('/:').strip()
218    file_name = re.sub(r"[^\-_\.\w\d]+", "_", file_name)
219    path = Path(download_dir, file_name)
220    if not path.exists():
221        download_dir.mkdir(exist_ok=True, parents=True)
222        download(url, path)
223    return path
224
225
226def download(url, dest):
227    with urlopen(url) as f:
228        data = f.read()
229
230    with open(dest, "wb") as f:
231        f.write(data)
232
233    assert Path(dest).exists()
234
235
236def core_metadata(dist) -> str:
237    with io.StringIO() as buffer:
238        dist.metadata.write_pkg_file(buffer)
239        value = "\n".join(buffer.getvalue().strip().splitlines())
240
241    # ---- DIFF NORMALISATION ----
242    # PEP 621 is very particular about author/maintainer metadata conversion, so skip
243    value = re.sub(r"^(Author|Maintainer)(-email)?:.*$", "", value, flags=re.M)
244    # May be redundant with Home-page
245    value = re.sub(r"^Project-URL: Homepage,.*$", "", value, flags=re.M)
246    # May be missing in original (relying on default) but backfilled in the TOML
247    value = re.sub(r"^Description-Content-Type:.*$", "", value, flags=re.M)
248    # ini2toml can automatically convert `tests_require` to `testing` extra
249    value = value.replace("Provides-Extra: testing\n", "")
250    # Remove empty lines
251    value = re.sub(r"^\s*$", "", value, flags=re.M)
252    value = re.sub(r"^\n", "", value, flags=re.M)
253
254    return value
255