1import logging 2from configparser import ConfigParser 3from inspect import cleandoc 4 5import pytest 6import tomli_w 7from path import Path as _Path 8 9from setuptools.config.pyprojecttoml import ( 10 read_configuration, 11 expand_configuration, 12 validate, 13) 14from setuptools.errors import OptionError 15 16 17import setuptools # noqa -- force distutils.core to be patched 18import distutils.core 19 20EXAMPLE = """ 21[project] 22name = "myproj" 23keywords = ["some", "key", "words"] 24dynamic = ["version", "readme"] 25requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 26dependencies = [ 27 'importlib-metadata>=0.12;python_version<"3.8"', 28 'importlib-resources>=1.0;python_version<"3.7"', 29 'pathlib2>=2.3.3,<3;python_version < "3.4" and sys.platform != "win32"', 30] 31 32[project.optional-dependencies] 33docs = [ 34 "sphinx>=3", 35 "sphinx-argparse>=0.2.5", 36 "sphinx-rtd-theme>=0.4.3", 37] 38testing = [ 39 "pytest>=1", 40 "coverage>=3,<5", 41] 42 43[project.scripts] 44exec = "pkg.__main__:exec" 45 46[build-system] 47requires = ["setuptools", "wheel"] 48build-backend = "setuptools.build_meta" 49 50[tool.setuptools] 51package-dir = {"" = "src"} 52zip-safe = true 53platforms = ["any"] 54 55[tool.setuptools.packages.find] 56where = ["src"] 57 58[tool.setuptools.cmdclass] 59sdist = "pkg.mod.CustomSdist" 60 61[tool.setuptools.dynamic.version] 62attr = "pkg.__version__.VERSION" 63 64[tool.setuptools.dynamic.readme] 65file = ["README.md"] 66content-type = "text/markdown" 67 68[tool.setuptools.package-data] 69"*" = ["*.txt"] 70 71[tool.setuptools.data-files] 72"data" = ["_files/*.txt"] 73 74[tool.distutils.sdist] 75formats = "gztar" 76 77[tool.distutils.bdist_wheel] 78universal = true 79""" 80 81 82def create_example(path, pkg_root): 83 pyproject = path / "pyproject.toml" 84 85 files = [ 86 f"{pkg_root}/pkg/__init__.py", 87 "_files/file.txt", 88 ] 89 if pkg_root != ".": # flat-layout will raise error for multi-package dist 90 # Ensure namespaces are discovered 91 files.append(f"{pkg_root}/other/nested/__init__.py") 92 93 for file in files: 94 (path / file).parent.mkdir(exist_ok=True, parents=True) 95 (path / file).touch() 96 97 pyproject.write_text(EXAMPLE) 98 (path / "README.md").write_text("hello world") 99 (path / f"{pkg_root}/pkg/mod.py").write_text("class CustomSdist: pass") 100 (path / f"{pkg_root}/pkg/__version__.py").write_text("VERSION = (3, 10)") 101 (path / f"{pkg_root}/pkg/__main__.py").write_text("def exec(): print('hello')") 102 103 104def verify_example(config, path, pkg_root): 105 pyproject = path / "pyproject.toml" 106 pyproject.write_text(tomli_w.dumps(config), encoding="utf-8") 107 expanded = expand_configuration(config, path) 108 expanded_project = expanded["project"] 109 assert read_configuration(pyproject, expand=True) == expanded 110 assert expanded_project["version"] == "3.10" 111 assert expanded_project["readme"]["text"] == "hello world" 112 assert "packages" in expanded["tool"]["setuptools"] 113 if pkg_root == ".": 114 # Auto-discovery will raise error for multi-package dist 115 assert set(expanded["tool"]["setuptools"]["packages"]) == {"pkg"} 116 else: 117 assert set(expanded["tool"]["setuptools"]["packages"]) == { 118 "pkg", 119 "other", 120 "other.nested", 121 } 122 assert expanded["tool"]["setuptools"]["include-package-data"] is True 123 assert "" in expanded["tool"]["setuptools"]["package-data"] 124 assert "*" not in expanded["tool"]["setuptools"]["package-data"] 125 assert expanded["tool"]["setuptools"]["data-files"] == [ 126 ("data", ["_files/file.txt"]) 127 ] 128 129 130def test_read_configuration(tmp_path): 131 create_example(tmp_path, "src") 132 pyproject = tmp_path / "pyproject.toml" 133 134 config = read_configuration(pyproject, expand=False) 135 assert config["project"].get("version") is None 136 assert config["project"].get("readme") is None 137 138 verify_example(config, tmp_path, "src") 139 140 141@pytest.mark.parametrize( 142 "pkg_root, opts", 143 [ 144 (".", {}), 145 ("src", {}), 146 ("lib", {"packages": {"find": {"where": ["lib"]}}}), 147 ], 148) 149def test_discovered_package_dir_with_attr_directive_in_config(tmp_path, pkg_root, opts): 150 create_example(tmp_path, pkg_root) 151 152 pyproject = tmp_path / "pyproject.toml" 153 154 config = read_configuration(pyproject, expand=False) 155 assert config["project"].get("version") is None 156 assert config["project"].get("readme") is None 157 config["tool"]["setuptools"].pop("packages", None) 158 config["tool"]["setuptools"].pop("package-dir", None) 159 160 config["tool"]["setuptools"].update(opts) 161 verify_example(config, tmp_path, pkg_root) 162 163 164ENTRY_POINTS = { 165 "console_scripts": {"a": "mod.a:func"}, 166 "gui_scripts": {"b": "mod.b:func"}, 167 "other": {"c": "mod.c:func [extra]"}, 168} 169 170 171def test_expand_entry_point(tmp_path): 172 entry_points = ConfigParser() 173 entry_points.read_dict(ENTRY_POINTS) 174 with open(tmp_path / "entry-points.txt", "w") as f: 175 entry_points.write(f) 176 177 tool = {"setuptools": {"dynamic": {"entry-points": {"file": "entry-points.txt"}}}} 178 project = {"dynamic": ["scripts", "gui-scripts", "entry-points"]} 179 pyproject = {"project": project, "tool": tool} 180 expanded = expand_configuration(pyproject, tmp_path) 181 expanded_project = expanded["project"] 182 assert len(expanded_project["scripts"]) == 1 183 assert expanded_project["scripts"]["a"] == "mod.a:func" 184 assert len(expanded_project["gui-scripts"]) == 1 185 assert expanded_project["gui-scripts"]["b"] == "mod.b:func" 186 assert len(expanded_project["entry-points"]) == 1 187 assert expanded_project["entry-points"]["other"]["c"] == "mod.c:func [extra]" 188 189 project = {"dynamic": ["entry-points"]} 190 pyproject = {"project": project, "tool": tool} 191 expanded = expand_configuration(pyproject, tmp_path) 192 expanded_project = expanded["project"] 193 assert len(expanded_project["entry-points"]) == 3 194 assert "scripts" not in expanded_project 195 assert "gui-scripts" not in expanded_project 196 197 198class TestClassifiers: 199 def test_dynamic(self, tmp_path): 200 # Let's create a project example that has dynamic classifiers 201 # coming from a txt file. 202 create_example(tmp_path, "src") 203 classifiers = """\ 204 Framework :: Flask 205 Programming Language :: Haskell 206 """ 207 (tmp_path / "classifiers.txt").write_text(cleandoc(classifiers)) 208 209 pyproject = tmp_path / "pyproject.toml" 210 config = read_configuration(pyproject, expand=False) 211 dynamic = config["project"]["dynamic"] 212 config["project"]["dynamic"] = list({*dynamic, "classifiers"}) 213 dynamic_config = config["tool"]["setuptools"]["dynamic"] 214 dynamic_config["classifiers"] = {"file": "classifiers.txt"} 215 216 # When the configuration is expanded, 217 # each line of the file should be an different classifier. 218 validate(config, pyproject) 219 expanded = expand_configuration(config, tmp_path) 220 221 assert set(expanded["project"]["classifiers"]) == { 222 "Framework :: Flask", 223 "Programming Language :: Haskell", 224 } 225 226 def test_dynamic_without_config(self, tmp_path): 227 config = """ 228 [project] 229 name = "myproj" 230 version = '42' 231 dynamic = ["classifiers"] 232 """ 233 234 pyproject = tmp_path / "pyproject.toml" 235 pyproject.write_text(cleandoc(config)) 236 with pytest.raises(OptionError, match="No configuration found"): 237 read_configuration(pyproject) 238 239 def test_dynamic_without_file(self, tmp_path): 240 config = """ 241 [project] 242 name = "myproj" 243 version = '42' 244 dynamic = ["classifiers"] 245 246 [tool.setuptools.dynamic] 247 classifiers = {file = ["classifiers.txt"]} 248 """ 249 250 pyproject = tmp_path / "pyproject.toml" 251 pyproject.write_text(cleandoc(config)) 252 with pytest.warns(UserWarning, match="File .*classifiers.txt. cannot be found"): 253 expanded = read_configuration(pyproject) 254 assert not expanded["project"]["classifiers"] 255 256 257@pytest.mark.parametrize( 258 "example", 259 ( 260 """ 261 [project] 262 name = "myproj" 263 version = "1.2" 264 265 [my-tool.that-disrespect.pep518] 266 value = 42 267 """, 268 ), 269) 270def test_ignore_unrelated_config(tmp_path, example): 271 pyproject = tmp_path / "pyproject.toml" 272 pyproject.write_text(cleandoc(example)) 273 274 # Make sure no error is raised due to 3rd party configs in pyproject.toml 275 assert read_configuration(pyproject) is not None 276 277 278@pytest.mark.parametrize( 279 "example, error_msg, value_shown_in_debug", 280 [ 281 ( 282 """ 283 [project] 284 name = "myproj" 285 version = "1.2" 286 requires = ['pywin32; platform_system=="Windows"' ] 287 """, 288 "configuration error: `project` must not contain {'requires'} properties", 289 '"requires": ["pywin32; platform_system==\\"Windows\\""]', 290 ), 291 ], 292) 293def test_invalid_example(tmp_path, caplog, example, error_msg, value_shown_in_debug): 294 caplog.set_level(logging.DEBUG) 295 pyproject = tmp_path / "pyproject.toml" 296 pyproject.write_text(cleandoc(example)) 297 298 caplog.clear() 299 with pytest.raises(ValueError, match="invalid pyproject.toml"): 300 read_configuration(pyproject) 301 302 # Make sure the logs give guidance to the user 303 error_log = caplog.record_tuples[0] 304 assert error_log[1] == logging.ERROR 305 assert error_msg in error_log[2] 306 307 debug_log = caplog.record_tuples[1] 308 assert debug_log[1] == logging.DEBUG 309 debug_msg = "".join(line.strip() for line in debug_log[2].splitlines()) 310 assert value_shown_in_debug in debug_msg 311 312 313@pytest.mark.parametrize("config", ("", "[tool.something]\nvalue = 42")) 314def test_empty(tmp_path, config): 315 pyproject = tmp_path / "pyproject.toml" 316 pyproject.write_text(config) 317 318 # Make sure no error is raised 319 assert read_configuration(pyproject) == {} 320 321 322@pytest.mark.parametrize("config", ("[project]\nname = 'myproj'\nversion='42'\n",)) 323def test_include_package_data_by_default(tmp_path, config): 324 """Builds with ``pyproject.toml`` should consider ``include-package-data=True`` as 325 default. 326 """ 327 pyproject = tmp_path / "pyproject.toml" 328 pyproject.write_text(config) 329 330 config = read_configuration(pyproject) 331 assert config["tool"]["setuptools"]["include-package-data"] is True 332 333 334def test_include_package_data_in_setuppy(tmp_path): 335 """Builds with ``pyproject.toml`` should consider ``include_package_data`` set in 336 ``setup.py``. 337 338 See https://github.com/pypa/setuptools/issues/3197#issuecomment-1079023889 339 """ 340 pyproject = tmp_path / "pyproject.toml" 341 pyproject.write_text("[project]\nname = 'myproj'\nversion='42'\n") 342 setuppy = tmp_path / "setup.py" 343 setuppy.write_text("__import__('setuptools').setup(include_package_data=False)") 344 345 with _Path(tmp_path): 346 dist = distutils.core.run_setup("setup.py", {}, stop_after="config") 347 348 assert dist.get_name() == "myproj" 349 assert dist.get_version() == "42" 350 assert dist.include_package_data is False 351