test_pyprojecttoml.py 13 KB

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