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