Repository: pypa/setuptools

Issue:
Error with dynamic classifiers in `pyproject.toml`
Previously reported in #3195 by @domdfcoding:


There is a separate issue where if `classifiers` *is* in `dynamic`, `setuptools` throws an error:

```
  File "/tmp/build-env-lbp441r9/lib/python3.8/site-packages/setuptools/dist.py", line 836, in parse_config_files
    pyprojecttoml.apply_configuration(self, filename, ignore_option_errors)
  File "/tmp/build-env-lbp441r9/lib/python3.8/site-packages/setuptools/config/pyprojecttoml.py", line 52, in apply_configuration
    config = read_configuration(filepath, True, ignore_option_errors, dist)
  File "/tmp/build-env-lbp441r9/lib/python3.8/site-packages/setuptools/config/pyprojecttoml.py", line 118, in read_configuration
    return expand_configuration(asdict, root_dir, ignore_option_errors, dist)
  File "/tmp/build-env-lbp441r9/lib/python3.8/site-packages/setuptools/config/pyprojecttoml.py", line 163, in expand_configuration
    _expand_all_dynamic(project_cfg, setuptools_cfg, package_dir, root_dir, ignore)
  File "/tmp/build-env-lbp441r9/lib/python3.8/site-packages/setuptools/config/pyprojecttoml.py", line 252, in _expand_all_dynamic
    project_cfg["classifiers"] = value.splitlines()
AttributeError: 'NoneType' object has no attribute 'splitlines'
```


Discussion:

Resolving patch:
diff --git a/setuptools/config/expand.py b/setuptools/config/expand.py
--- a/setuptools/config/expand.py
+++ b/setuptools/config/expand.py
@@ -20,6 +20,7 @@
 import io
 import os
 import sys
+import warnings
 from glob import iglob
 from configparser import ConfigParser
 from importlib.machinery import ModuleSpec
@@ -124,18 +125,25 @@ def read_files(filepaths: Union[str, bytes, Iterable[_Path]], root_dir=None) ->
 
     (By default ``root_dir`` is the current directory).
     """
-    if isinstance(filepaths, (str, bytes)):
-        filepaths = [filepaths]  # type: ignore
+    from setuptools.extern.more_itertools import always_iterable
 
     root_dir = os.path.abspath(root_dir or os.getcwd())
-    _filepaths = (os.path.join(root_dir, path) for path in filepaths)
+    _filepaths = (os.path.join(root_dir, path) for path in always_iterable(filepaths))
     return '\n'.join(
         _read_file(path)
-        for path in _filepaths
-        if _assert_local(path, root_dir) and os.path.isfile(path)
+        for path in _filter_existing_files(_filepaths)
+        if _assert_local(path, root_dir)
     )
 
 
+def _filter_existing_files(filepaths: Iterable[_Path]) -> Iterator[_Path]:
+    for path in filepaths:
+        if os.path.isfile(path):
+            yield path
+        else:
+            warnings.warn(f"File {path!r} cannot be found")
+
+
 def _read_file(filepath: Union[bytes, _Path]) -> str:
     with io.open(filepath, encoding='utf-8') as f:
         return f.read()
diff --git a/setuptools/config/pyprojecttoml.py b/setuptools/config/pyprojecttoml.py
--- a/setuptools/config/pyprojecttoml.py
+++ b/setuptools/config/pyprojecttoml.py
@@ -249,7 +249,7 @@ def _expand_all_dynamic(
 
     if "classifiers" in dynamic:
         value = _expand_dynamic(dynamic_cfg, "classifiers", pkg_dir, root_dir, ignore)
-        project_cfg["classifiers"] = value.splitlines()
+        project_cfg["classifiers"] = (value or "").splitlines()
 
 
 def _expand_dynamic(


Test patch:
diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py
--- a/setuptools/tests/config/test_apply_pyprojecttoml.py
+++ b/setuptools/tests/config/test_apply_pyprojecttoml.py
@@ -141,7 +141,7 @@ def _pep621_example_project(tmp_path, readme="README.rst"):
         text = text.replace(orig, subst)
     pyproject.write_text(text)
 
-    (tmp_path / "README.rst").write_text("hello world")
+    (tmp_path / readme).write_text("hello world")
     (tmp_path / "LICENSE.txt").write_text("--- LICENSE stub ---")
     (tmp_path / "spam.py").write_text(PEP621_EXAMPLE_SCRIPT)
     return pyproject
diff --git a/setuptools/tests/config/test_expand.py b/setuptools/tests/config/test_expand.py
--- a/setuptools/tests/config/test_expand.py
+++ b/setuptools/tests/config/test_expand.py
@@ -34,15 +34,19 @@ def test_glob_relative(tmp_path, monkeypatch):
 
 
 def test_read_files(tmp_path, monkeypatch):
+
+    dir_ = tmp_path / "dir_"
+    (tmp_path / "_dir").mkdir(exist_ok=True)
+    (tmp_path / "a.txt").touch()
     files = {
         "a.txt": "a",
         "dir1/b.txt": "b",
         "dir1/dir2/c.txt": "c"
     }
-    write_files(files, tmp_path)
+    write_files(files, dir_)
 
     with monkeypatch.context() as m:
-        m.chdir(tmp_path)
+        m.chdir(dir_)
         assert expand.read_files(list(files)) == "a\nb\nc"
 
         cannot_access_msg = r"Cannot access '.*\.\..a\.txt'"
@@ -50,9 +54,9 @@ def test_read_files(tmp_path, monkeypatch):
             expand.read_files(["../a.txt"])
 
     # Make sure the same APIs work outside cwd
-    assert expand.read_files(list(files), tmp_path) == "a\nb\nc"
+    assert expand.read_files(list(files), dir_) == "a\nb\nc"
     with pytest.raises(DistutilsOptionError, match=cannot_access_msg):
-        expand.read_files(["../a.txt"], tmp_path)
+        expand.read_files(["../a.txt"], dir_)
 
 
 class TestReadAttr:
diff --git a/setuptools/tests/config/test_pyprojecttoml.py b/setuptools/tests/config/test_pyprojecttoml.py
--- a/setuptools/tests/config/test_pyprojecttoml.py
+++ b/setuptools/tests/config/test_pyprojecttoml.py
@@ -10,6 +10,8 @@
     expand_configuration,
     validate,
 )
+from setuptools.errors import OptionError
+
 
 EXAMPLE = """
 [project]
@@ -189,32 +191,63 @@ def test_expand_entry_point(tmp_path):
     assert "gui-scripts" not in expanded_project
 
 
-def test_dynamic_classifiers(tmp_path):
-    # Let's create a project example that has dynamic classifiers
-    # coming from a txt file.
-    create_example(tmp_path, "src")
-    classifiers = """\
-    Framework :: Flask
-    Programming Language :: Haskell
-    """
-    (tmp_path / "classifiers.txt").write_text(cleandoc(classifiers))
+class TestClassifiers:
+    def test_dynamic(self, tmp_path):
+        # Let's create a project example that has dynamic classifiers
+        # coming from a txt file.
+        create_example(tmp_path, "src")
+        classifiers = """\
+        Framework :: Flask
+        Programming Language :: Haskell
+        """
+        (tmp_path / "classifiers.txt").write_text(cleandoc(classifiers))
+
+        pyproject = tmp_path / "pyproject.toml"
+        config = read_configuration(pyproject, expand=False)
+        dynamic = config["project"]["dynamic"]
+        config["project"]["dynamic"] = list({*dynamic, "classifiers"})
+        dynamic_config = config["tool"]["setuptools"]["dynamic"]
+        dynamic_config["classifiers"] = {"file": "classifiers.txt"}
+
+        # When the configuration is expanded,
+        # each line of the file should be an different classifier.
+        validate(config, pyproject)
+        expanded = expand_configuration(config, tmp_path)
+
+        assert set(expanded["project"]["classifiers"]) == {
+            "Framework :: Flask",
+            "Programming Language :: Haskell",
+        }
 
-    pyproject = tmp_path / "pyproject.toml"
-    config = read_configuration(pyproject, expand=False)
-    dynamic = config["project"]["dynamic"]
-    config["project"]["dynamic"] = list({*dynamic, "classifiers"})
-    dynamic_config = config["tool"]["setuptools"]["dynamic"]
-    dynamic_config["classifiers"] = {"file": "classifiers.txt"}
-
-    # When the configuration is expanded,
-    # each line of the file should be an different classifier.
-    validate(config, pyproject)
-    expanded = expand_configuration(config, tmp_path)
-
-    assert set(expanded["project"]["classifiers"]) == {
-        "Framework :: Flask",
-        "Programming Language :: Haskell",
-    }
+    def test_dynamic_without_config(self, tmp_path):
+        config = """
+        [project]
+        name = "myproj"
+        version = '42'
+        dynamic = ["classifiers"]
+        """
+
+        pyproject = tmp_path / "pyproject.toml"
+        pyproject.write_text(cleandoc(config))
+        with pytest.raises(OptionError, match="No configuration found"):
+            read_configuration(pyproject)
+
+    def test_dynamic_without_file(self, tmp_path):
+        config = """
+        [project]
+        name = "myproj"
+        version = '42'
+        dynamic = ["classifiers"]
+
+        [tool.setuptools.dynamic]
+        classifiers = {file = ["classifiers.txt"]}
+        """
+
+        pyproject = tmp_path / "pyproject.toml"
+        pyproject.write_text(cleandoc(config))
+        with pytest.warns(UserWarning, match="File .*classifiers.txt. cannot be found"):
+            expanded = read_configuration(pyproject)
+        assert not expanded["project"]["classifiers"]
 
 
 @pytest.mark.parametrize(
diff --git a/setuptools/tests/config/test_setupcfg.py b/setuptools/tests/config/test_setupcfg.py
--- a/setuptools/tests/config/test_setupcfg.py
+++ b/setuptools/tests/config/test_setupcfg.py
@@ -185,9 +185,12 @@ def test_file_mixed(self, tmpdir):
 
     def test_file_sandboxed(self, tmpdir):
 
-        fake_env(tmpdir, '[metadata]\n' 'long_description = file: ../../README\n')
+        tmpdir.ensure("README")
+        project = tmpdir.join('depth1', 'depth2')
+        project.ensure(dir=True)
+        fake_env(project, '[metadata]\n' 'long_description = file: ../../README\n')
 
-        with get_dist(tmpdir, parse=False) as dist:
+        with get_dist(project, parse=False) as dist:
             with pytest.raises(DistutilsOptionError):
                 dist.parse_config_files()  # file: out of sandbox