codekingpro/portable-devtools
114k
1import re2from configparser import ConfigParser3from inspect import cleandoc4 5import jaraco.path6import pytest7import tomli_w8from path import Path9 10import setuptools # noqa: F401 # force distutils.core to be patched11from setuptools.config.pyprojecttoml import (12 _ToolsTypoInMetadata,13 apply_configuration,14 expand_configuration,15 read_configuration,16 validate,17)18from setuptools.dist import Distribution19from setuptools.errors import OptionError20 21import distutils.core22 23EXAMPLE = """24[project]25name = "myproj"26keywords = ["some", "key", "words"]27dynamic = ["version", "readme"]28requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"29dependencies = [30 'importlib-metadata>=0.12;python_version<"3.8"',31 'importlib-resources>=1.0;python_version<"3.7"',32 'pathlib2>=2.3.3,<3;python_version < "3.4" and sys.platform != "win32"',33]34 35[project.optional-dependencies]36docs = [37 "sphinx>=3",38 "sphinx-argparse>=0.2.5",39 "sphinx-rtd-theme>=0.4.3",40]41testing = [42 "pytest>=1",43 "coverage>=3,<5",44]45 46[project.scripts]47exec = "pkg.__main__:exec"48 49[build-system]50requires = ["setuptools", "wheel"]51build-backend = "setuptools.build_meta"52 53[tool.setuptools]54package-dir = {"" = "src"}55zip-safe = true56platforms = ["any"]57 58[tool.setuptools.packages.find]59where = ["src"]60 61[tool.setuptools.cmdclass]62sdist = "pkg.mod.CustomSdist"63 64[tool.setuptools.dynamic.version]65attr = "pkg.__version__.VERSION"66 67[tool.setuptools.dynamic.readme]68file = ["README.md"]69content-type = "text/markdown"70 71[tool.setuptools.package-data]72"*" = ["*.txt"]73 74[tool.setuptools.data-files]75"data" = ["_files/*.txt"]76 77[tool.distutils.sdist]78formats = "gztar"79 80[tool.distutils.bdist_wheel]81universal = true82"""83 84 85def create_example(path, pkg_root):86 files = {87 "pyproject.toml": EXAMPLE,88 "README.md": "hello world",89 "_files": {90 "file.txt": "",91 },92 }93 packages = {94 "pkg": {95 "__init__.py": "",96 "mod.py": "class CustomSdist: pass",97 "__version__.py": "VERSION = (3, 10)",98 "__main__.py": "def exec(): print('hello')",99 },100 }101 102 assert pkg_root # Meta-test: cannot be empty string.103 104 if pkg_root == ".":105 files = {**files, **packages}106 # skip other files: flat-layout will raise error for multi-package dist107 else:108 # Use this opportunity to ensure namespaces are discovered109 files[pkg_root] = {**packages, "other": {"nested": {"__init__.py": ""}}}110 111 jaraco.path.build(files, prefix=path)112 113 114def verify_example(config, path, pkg_root):115 pyproject = path / "pyproject.toml"116 pyproject.write_text(tomli_w.dumps(config), encoding="utf-8")117 expanded = expand_configuration(config, path)118 expanded_project = expanded["project"]119 assert read_configuration(pyproject, expand=True) == expanded120 assert expanded_project["version"] == "3.10"121 assert expanded_project["readme"]["text"] == "hello world"122 assert "packages" in expanded["tool"]["setuptools"]123 if pkg_root == ".":124 # Auto-discovery will raise error for multi-package dist125 assert set(expanded["tool"]["setuptools"]["packages"]) == {"pkg"}126 else:127 assert set(expanded["tool"]["setuptools"]["packages"]) == {128 "pkg",129 "other",130 "other.nested",131 }132 assert expanded["tool"]["setuptools"]["include-package-data"] is True133 assert "" in expanded["tool"]["setuptools"]["package-data"]134 assert "*" not in expanded["tool"]["setuptools"]["package-data"]135 assert expanded["tool"]["setuptools"]["data-files"] == [136 ("data", ["_files/file.txt"])137 ]138 139 140def test_read_configuration(tmp_path):141 create_example(tmp_path, "src")142 pyproject = tmp_path / "pyproject.toml"143 144 config = read_configuration(pyproject, expand=False)145 assert config["project"].get("version") is None146 assert config["project"].get("readme") is None147 148 verify_example(config, tmp_path, "src")149 150 151@pytest.mark.parametrize(152 ("pkg_root", "opts"),153 [154 (".", {}),155 ("src", {}),156 ("lib", {"packages": {"find": {"where": ["lib"]}}}),157 ],158)159def test_discovered_package_dir_with_attr_directive_in_config(tmp_path, pkg_root, opts):160 create_example(tmp_path, pkg_root)161 162 pyproject = tmp_path / "pyproject.toml"163 164 config = read_configuration(pyproject, expand=False)165 assert config["project"].get("version") is None166 assert config["project"].get("readme") is None167 config["tool"]["setuptools"].pop("packages", None)168 config["tool"]["setuptools"].pop("package-dir", None)169 170 config["tool"]["setuptools"].update(opts)171 verify_example(config, tmp_path, pkg_root)172 173 174ENTRY_POINTS = {175 "console_scripts": {"a": "mod.a:func"},176 "gui_scripts": {"b": "mod.b:func"},177 "other": {"c": "mod.c:func [extra]"},178}179 180 181class TestEntryPoints:182 def write_entry_points(self, tmp_path):183 entry_points = ConfigParser()184 entry_points.read_dict(ENTRY_POINTS)185 with open(tmp_path / "entry-points.txt", "w", encoding="utf-8") as f:186 entry_points.write(f)187 188 def pyproject(self, dynamic=None):189 project = {"dynamic": dynamic or ["scripts", "gui-scripts", "entry-points"]}190 tool = {"dynamic": {"entry-points": {"file": "entry-points.txt"}}}191 return {"project": project, "tool": {"setuptools": tool}}192 193 def test_all_listed_in_dynamic(self, tmp_path):194 self.write_entry_points(tmp_path)195 expanded = expand_configuration(self.pyproject(), tmp_path)196 expanded_project = expanded["project"]197 assert len(expanded_project["scripts"]) == 1198 assert expanded_project["scripts"]["a"] == "mod.a:func"199 assert len(expanded_project["gui-scripts"]) == 1200 assert expanded_project["gui-scripts"]["b"] == "mod.b:func"201 assert len(expanded_project["entry-points"]) == 1202 assert expanded_project["entry-points"]["other"]["c"] == "mod.c:func [extra]"203 204 @pytest.mark.parametrize("missing_dynamic", ("scripts", "gui-scripts"))205 def test_scripts_not_listed_in_dynamic(self, tmp_path, missing_dynamic):206 self.write_entry_points(tmp_path)207 dynamic = {"scripts", "gui-scripts", "entry-points"} - {missing_dynamic}208 209 msg = f"defined outside of `pyproject.toml`:.*{missing_dynamic}"210 with pytest.raises(OptionError, match=re.compile(msg, re.DOTALL)):211 expand_configuration(self.pyproject(dynamic), tmp_path)212 213 214class TestClassifiers:215 def test_dynamic(self, tmp_path):216 # Let's create a project example that has dynamic classifiers217 # coming from a txt file.218 create_example(tmp_path, "src")219 classifiers = cleandoc(220 """221 Framework :: Flask222 Programming Language :: Haskell223 """224 )225 (tmp_path / "classifiers.txt").write_text(classifiers, encoding="utf-8")226 227 pyproject = tmp_path / "pyproject.toml"228 config = read_configuration(pyproject, expand=False)229 dynamic = config["project"]["dynamic"]230 config["project"]["dynamic"] = list({*dynamic, "classifiers"})231 dynamic_config = config["tool"]["setuptools"]["dynamic"]232 dynamic_config["classifiers"] = {"file": "classifiers.txt"}233 234 # When the configuration is expanded,235 # each line of the file should be an different classifier.236 validate(config, pyproject)237 expanded = expand_configuration(config, tmp_path)238 239 assert set(expanded["project"]["classifiers"]) == {240 "Framework :: Flask",241 "Programming Language :: Haskell",242 }243 244 def test_dynamic_without_config(self, tmp_path):245 config = """246 [project]247 name = "myproj"248 version = '42'249 dynamic = ["classifiers"]250 """251 252 pyproject = tmp_path / "pyproject.toml"253 pyproject.write_text(cleandoc(config), encoding="utf-8")254 with pytest.raises(OptionError, match="No configuration .* .classifiers."):255 read_configuration(pyproject)256 257 def test_dynamic_readme_from_setup_script_args(self, tmp_path):258 config = """259 [project]260 name = "myproj"261 version = '42'262 dynamic = ["readme"]263 """264 pyproject = tmp_path / "pyproject.toml"265 pyproject.write_text(cleandoc(config), encoding="utf-8")266 dist = Distribution(attrs={"long_description": "42"})267 # No error should occur because of missing `readme`268 dist = apply_configuration(dist, pyproject)269 assert dist.metadata.long_description == "42"270 271 def test_dynamic_without_file(self, tmp_path):272 config = """273 [project]274 name = "myproj"275 version = '42'276 dynamic = ["classifiers"]277 278 [tool.setuptools.dynamic]279 classifiers = {file = ["classifiers.txt"]}280 """281 282 pyproject = tmp_path / "pyproject.toml"283 pyproject.write_text(cleandoc(config), encoding="utf-8")284 with pytest.warns(UserWarning, match="File .*classifiers.txt. cannot be found"):285 expanded = read_configuration(pyproject)286 assert "classifiers" not in expanded["project"]287 288 289class TestImportNames:290 EXAMPLES = [291 'import-names = ["hello", "world"]',292 'import-namespaces = ["hello", "world"]',293 'dynamic = ["import-names"]',294 'dynamic = ["import-namespaces"]',295 ]296 297 @pytest.mark.parametrize("example", EXAMPLES)298 def test_not_implemented(self, monkeypatch, tmp_path, example):299 monkeypatch.chdir(tmp_path)300 pyproject = Path("pyproject.toml")301 toml_config = f"""302 [project]303 name = 'proj'304 version = '42'305 {example}306 """307 pyproject.write_text(cleandoc(toml_config), encoding="utf-8")308 with pytest.raises(NotImplementedError, match='import-names'):309 apply_configuration(Distribution({}), pyproject)310 311 312@pytest.mark.parametrize(313 "example",314 (315 """316 [project]317 name = "myproj"318 version = "1.2"319 320 [my-tool.that-disrespect.pep518]321 value = 42322 """,323 ),324)325def test_ignore_unrelated_config(tmp_path, example):326 pyproject = tmp_path / "pyproject.toml"327 pyproject.write_text(cleandoc(example), encoding="utf-8")328 329 # Make sure no error is raised due to 3rd party configs in pyproject.toml330 assert read_configuration(pyproject) is not None331 332 333@pytest.mark.parametrize(334 ("example", "error_msg"),335 [336 (337 """338 [project]339 name = "myproj"340 version = "1.2"341 requires = ['pywin32; platform_system=="Windows"' ]342 """,343 "configuration error: .project. must not contain ..requires.. properties",344 ),345 ],346)347def test_invalid_example(tmp_path, example, error_msg):348 pyproject = tmp_path / "pyproject.toml"349 pyproject.write_text(cleandoc(example), encoding="utf-8")350 351 pattern = re.compile(352 f"invalid pyproject.toml.*{error_msg}.*", re.MULTILINE | re.DOTALL353 )354 with pytest.raises(ValueError, match=pattern):355 read_configuration(pyproject)356 357 358@pytest.mark.parametrize("config", ("", "[tool.something]\nvalue = 42"))359def test_empty(tmp_path, config):360 pyproject = tmp_path / "pyproject.toml"361 pyproject.write_text(config, encoding="utf-8")362 363 # Make sure no error is raised364 assert read_configuration(pyproject) == {}365 366 367@pytest.mark.parametrize("config", ("[project]\nname = 'myproj'\nversion='42'\n",))368def test_include_package_data_by_default(tmp_path, config):369 """Builds with ``pyproject.toml`` should consider ``include-package-data=True`` as370 default.371 """372 pyproject = tmp_path / "pyproject.toml"373 pyproject.write_text(config, encoding="utf-8")374 375 config = read_configuration(pyproject)376 assert config["tool"]["setuptools"]["include-package-data"] is True377 378 379def test_include_package_data_in_setuppy(tmp_path):380 """Builds with ``pyproject.toml`` should consider ``include_package_data`` set in381 ``setup.py``.382 383 See https://github.com/pypa/setuptools/issues/3197#issuecomment-1079023889384 """385 files = {386 "pyproject.toml": "[project]\nname = 'myproj'\nversion='42'\n",387 "setup.py": "__import__('setuptools').setup(include_package_data=False)",388 }389 jaraco.path.build(files, prefix=tmp_path)390 391 with Path(tmp_path):392 dist = distutils.core.run_setup("setup.py", {}, stop_after="config")393 394 assert dist.get_name() == "myproj"395 assert dist.get_version() == "42"396 assert dist.include_package_data is False397 398 399def test_warn_tools_typo(tmp_path):400 """Test that the common ``tools.setuptools`` typo in ``pyproject.toml`` issues a warning401 402 See https://github.com/pypa/setuptools/issues/4150403 """404 config = """405 [build-system]406 requires = ["setuptools"]407 build-backend = "setuptools.build_meta"408 409 [project]410 name = "myproj"411 version = '42'412 413 [tools.setuptools]414 packages = ["package"]415 """416 417 pyproject = tmp_path / "pyproject.toml"418 pyproject.write_text(cleandoc(config), encoding="utf-8")419 420 with pytest.warns(_ToolsTypoInMetadata):421 read_configuration(pyproject)422 