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