Repository: pypa/setuptools

Issue:
[BUG] [pyproject.toml] Setuptools improperly backfills values in pyproject.toml
### setuptools version

setuptools==61.0.0 

### Python version

Python 3.8

### OS

Ubuntu

### Additional environment information

_No response_

### Description

When using `pyproject.toml` for configuration alongside `setup.cfg`, `setuptools` will use the value for `classifiers` from `setup.cfg` if it is not given in `pyprojet.toml` *even if*`classifiers` is not listed in `dynamic`. This is not allowed under PEP 621[1]

> If the metadata does not list a field in dynamic, then a build back-end CANNOT fill in the requisite metadata on behalf of the user (i.e. dynamic is the only way to allow a tool to fill in metadata and the user must opt into the filling in).


[1] https://peps.python.org/pep-0621/#dynamic

### Expected behavior

`setuptools` should only take the value from `setup.cfg` is `classifiers` is listed in `dynamic`.

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'
```


### How to Reproduce

1. Clone the following gist: https://gist.github.com/domdfcoding/88f2b9d8e7cfd3c39a3404c3be6e4c87
2. Build the project with `python3 -m pip wheel .`
3. Inspect the METADATA file in the wheel, and observe the presence of the `Development Status :: 5 - Production/Stable
` classifier.

### Output

N/A


Discussion:
Thank you very much @domdfcoding for checking this.
Yeah it is definitely a bug and an edge case that I do not foresee coming.

Resolving patch:
diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py
--- a/setuptools/config/_apply_pyprojecttoml.py
+++ b/setuptools/config/_apply_pyprojecttoml.py
@@ -7,9 +7,10 @@
 """
 import logging
 import os
+import warnings
 from collections.abc import Mapping
 from email.headerregistry import Address
-from functools import partial
+from functools import partial, reduce
 from itertools import chain
 from types import MappingProxyType
 from typing import (TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple,
@@ -35,9 +36,29 @@ def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution"
         return dist  # short-circuit unrelated pyproject.toml file
 
     root_dir = os.path.dirname(filename) or "."
-    tool_table = config.get("tool", {}).get("setuptools", {})
+
+    _apply_project_table(dist, config, root_dir)
+    _apply_tool_table(dist, config, filename)
+
+    current_directory = os.getcwd()
+    os.chdir(root_dir)
+    try:
+        dist._finalize_requires()
+        dist._finalize_license_files()
+    finally:
+        os.chdir(current_directory)
+
+    return dist
+
+
+def _apply_project_table(dist: "Distribution", config: dict, root_dir: _Path):
     project_table = config.get("project", {}).copy()
+    if not project_table:
+        return  # short-circuit
+
+    _handle_missing_dynamic(dist, project_table)
     _unify_entry_points(project_table)
+
     for field, value in project_table.items():
         norm_key = json_compatible_key(field)
         corresp = PYPROJECT_CORRESPONDENCE.get(norm_key, norm_key)
@@ -46,6 +67,12 @@ def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution"
         else:
             _set_config(dist, corresp, value)
 
+
+def _apply_tool_table(dist: "Distribution", config: dict, filename: _Path):
+    tool_table = config.get("tool", {}).get("setuptools", {})
+    if not tool_table:
+        return  # short-circuit
+
     for field, value in tool_table.items():
         norm_key = json_compatible_key(field)
         norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key)
@@ -53,15 +80,17 @@ def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution"
 
     _copy_command_options(config, dist, filename)
 
-    current_directory = os.getcwd()
-    os.chdir(root_dir)
-    try:
-        dist._finalize_requires()
-        dist._finalize_license_files()
-    finally:
-        os.chdir(current_directory)
 
-    return dist
+def _handle_missing_dynamic(dist: "Distribution", project_table: dict):
+    """Be temporarily forgiving with ``dynamic`` fields not listed in ``dynamic``"""
+    # TODO: Set fields back to `None` once the feature stabilizes
+    dynamic = set(project_table.get("dynamic", []))
+    for field, getter in _PREVIOUSLY_DEFINED.items():
+        if not (field in project_table or field in dynamic):
+            value = getter(dist)
+            if value:
+                msg = _WouldIgnoreField.message(field, value)
+                warnings.warn(msg, _WouldIgnoreField)
 
 
 def json_compatible_key(key: str) -> str:
@@ -235,6 +264,39 @@ def _normalise_cmd_options(desc: List[Tuple[str, Optional[str], str]]) -> Set[st
     return {_normalise_cmd_option_key(fancy_option[0]) for fancy_option in desc}
 
 
+def _attrgetter(attr):
+    """
+    Similar to ``operator.attrgetter`` but returns None if ``attr`` is not found
+    >>> from types import SimpleNamespace
+    >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))
+    >>> _attrgetter("a")(obj)
+    42
+    >>> _attrgetter("b.c")(obj)
+    13
+    >>> _attrgetter("d")(obj) is None
+    True
+    """
+    return partial(reduce, lambda acc, x: getattr(acc, x, None), attr.split("."))
+
+
+def _some_attrgetter(*items):
+    """
+    Return the first "truth-y" attribute or None
+    >>> from types import SimpleNamespace
+    >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))
+    >>> _some_attrgetter("d", "a", "b.c")(obj)
+    42
+    >>> _some_attrgetter("d", "e", "b.c", "a")(obj)
+    13
+    >>> _some_attrgetter("d", "e", "f")(obj) is None
+    True
+    """
+    def _acessor(obj):
+        values = (_attrgetter(i)(obj) for i in items)
+        return next((i for i in values if i), None)
+    return _acessor
+
+
 PYPROJECT_CORRESPONDENCE: Dict[str, _Correspondence] = {
     "readme": _long_description,
     "license": _license,
@@ -250,3 +312,53 @@ def _normalise_cmd_options(desc: List[Tuple[str, Optional[str], str]]) -> Set[st
 
 SETUPTOOLS_PATCHES = {"long_description_content_type", "project_urls",
                       "provides_extras", "license_file", "license_files"}
+
+_PREVIOUSLY_DEFINED = {
+    "name": _attrgetter("metadata.name"),
+    "version": _attrgetter("metadata.version"),
+    "description": _attrgetter("metadata.description"),
+    "readme": _attrgetter("metadata.long_description"),
+    "requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),
+    "license": _attrgetter("metadata.license"),
+    "authors": _some_attrgetter("metadata.author", "metadata.author_email"),
+    "maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),
+    "keywords": _attrgetter("metadata.keywords"),
+    "classifiers": _attrgetter("metadata.classifiers"),
+    "urls": _attrgetter("metadata.project_urls"),
+    "entry-points": _attrgetter("entry_points"),
+    "dependencies": _some_attrgetter("_orig_install_requires", "install_requires"),
+    "optional-dependencies": _some_attrgetter("_orig_extras_require", "extras_require"),
+}
+
+
+class _WouldIgnoreField(UserWarning):
+    """Inform users that ``pyproject.toml`` would overwrite previously defined metadata:
+    !!\n\n
+    ##########################################################################
+    # configuration would be ignored/result in error due to `pyproject.toml` #
+    ##########################################################################
+
+    The following seems to be defined outside of `pyproject.toml`:
+
+    `{field} = {value!r}`
+
+    According to the spec (see the link bellow), however, setuptools CANNOT
+    consider this value unless {field!r} is listed as `dynamic`.
+
+    https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
+
+    For the time being, `setuptools` will still consider the given value (as a
+    **transitional** measure), but please note that future releases of setuptools will
+    follow strictly the standard.
+
+    To prevent this warning, you can list {field!r} under `dynamic` or alternatively
+    remove the `[project]` table from your file and rely entirely on other means of
+    configuration.
+    \n\n!!
+    """
+
+    @classmethod
+    def message(cls, field, value):
+        from inspect import cleandoc
+        msg = "\n".join(cls.__doc__.splitlines()[1:])
+        return cleandoc(msg.format(field=field, value=value))
diff --git a/setuptools/config/pyprojecttoml.py b/setuptools/config/pyprojecttoml.py
--- a/setuptools/config/pyprojecttoml.py
+++ b/setuptools/config/pyprojecttoml.py
@@ -9,7 +9,7 @@
 from setuptools.errors import FileError, OptionError
 
 from . import expand as _expand
-from ._apply_pyprojecttoml import apply
+from ._apply_pyprojecttoml import apply, _PREVIOUSLY_DEFINED, _WouldIgnoreField
 
 if TYPE_CHECKING:
     from setuptools.dist import Distribution  # noqa
@@ -167,7 +167,7 @@ def _skip_bad_config(
 def expand_configuration(
     config: dict,
     root_dir: Optional[_Path] = None,
-    ignore_option_errors=False,
+    ignore_option_errors: bool = False,
     dist: Optional["Distribution"] = None,
 ) -> dict:
     """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)
@@ -184,38 +184,190 @@ def expand_configuration(
 
     :rtype: dict
     """
-    root_dir = root_dir or os.getcwd()
-    project_cfg = config.get("project", {})
-    setuptools_cfg = config.get("tool", {}).get("setuptools", {})
-    ignore = ignore_option_errors
-
-    _expand_packages(setuptools_cfg, root_dir, ignore)
-    _canonic_package_data(setuptools_cfg)
-    _canonic_package_data(setuptools_cfg, "exclude-package-data")
+    return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()
 
-    # A distribution object is required for discovering the correct package_dir
-    dist = _ensure_dist(dist, project_cfg, root_dir)
 
-    with _EnsurePackagesDiscovered(dist, setuptools_cfg) as ensure_discovered:
-        package_dir = ensure_discovered.package_dir
-        process = partial(_process_field, ignore_option_errors=ignore)
+class _ConfigExpander:
+    def __init__(
+        self,
+        config: dict,
+        root_dir: Optional[_Path] = None,
+        ignore_option_errors: bool = False,
+        dist: Optional["Distribution"] = None,
+    ):
+        self.config = config
+        self.root_dir = root_dir or os.getcwd()
+        self.project_cfg = config.get("project", {})
+        self.dynamic = self.project_cfg.get("dynamic", [])
+        self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})
+        self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})
+        self.ignore_option_errors = ignore_option_errors
+        self._dist = dist
+
+    def _ensure_dist(self) -> "Distribution":
+        from setuptools.dist import Distribution
+
+        attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}
+        return self._dist or Distribution(attrs)
+
+    def _process_field(self, container: dict, field: str, fn: Callable):
+        if field in container:
+            with _ignore_errors(self.ignore_option_errors):
+                container[field] = fn(container[field])
+
+    def _canonic_package_data(self, field="package-data"):
+        package_data = self.setuptools_cfg.get(field, {})
+        return _expand.canonic_package_data(package_data)
+
+    def expand(self):
+        self._expand_packages()
+        self._canonic_package_data()
+        self._canonic_package_data("exclude-package-data")
+
+        # A distribution object is required for discovering the correct package_dir
+        dist = self._ensure_dist()
+
+        with _EnsurePackagesDiscovered(dist, self.setuptools_cfg) as ensure_discovered:
+            package_dir = ensure_discovered.package_dir
+            self._expand_data_files()
+            self._expand_cmdclass(package_dir)
+            self._expand_all_dynamic(dist, package_dir)
+
+        return self.config
+
+    def _expand_packages(self):
+        packages = self.setuptools_cfg.get("packages")
+        if packages is None or isinstance(packages, (list, tuple)):
+            return
+
+        find = packages.get("find")
+        if isinstance(find, dict):
+            find["root_dir"] = self.root_dir
+            find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})
+            with _ignore_errors(self.ignore_option_errors):
+                self.setuptools_cfg["packages"] = _expand.find_packages(**find)
+
+    def _expand_data_files(self):
+        data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)
+        self._process_field(self.setuptools_cfg, "data-files", data_files)
+
+    def _expand_cmdclass(self, package_dir: Mapping[str, str]):
+        root_dir = self.root_dir
         cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)
-        data_files = partial(_expand.canonic_data_files, root_dir=root_dir)
+        self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)
+
+    def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, str]):
+        special = (  # need special handling
+            "version",
+            "readme",
+            "entry-points",
+            "scripts",
+            "gui-scripts",
+            "classifiers",
+        )
+        # `_obtain` functions are assumed to raise appropriate exceptions/warnings.
+        obtained_dynamic = {
+            field: self._obtain(dist, field, package_dir)
+            for field in self.dynamic
+            if field not in special
+        }
+        obtained_dynamic.update(
+            self._obtain_entry_points(dist, package_dir) or {},
+            version=self._obtain_version(dist, package_dir),
+            readme=self._obtain_readme(dist),
+            classifiers=self._obtain_classifiers(dist),
+        )
+        # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
+        # might have already been set by setup.py/extensions, so avoid overwriting.
+        self.project_cfg.update({k: v for k, v in obtained_dynamic.items() if v})
+
+    def _ensure_previously_set(self, dist: "Distribution", field: str):
+        previous = _PREVIOUSLY_DEFINED[field](dist)
+        if not previous and not self.ignore_option_errors:
+            msg = (
+                f"No configuration found for dynamic {field!r}.\n"
+                "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"
+                "\nothers must be specified via the equivalent attribute in `setup.py`."
+            )
+            raise OptionError(msg)
+
+    def _obtain(self, dist: "Distribution", field: str, package_dir: Mapping[str, str]):
+        if field in self.dynamic_cfg:
+            directive = self.dynamic_cfg[field]
+            with _ignore_errors(self.ignore_option_errors):
+                root_dir = self.root_dir
+                if "file" in directive:
+                    return _expand.read_files(directive["file"], root_dir)
+                if "attr" in directive:
+                    return _expand.read_attr(directive["attr"], package_dir, root_dir)
+                msg = f"invalid `tool.setuptools.dynamic.{field}`: {directive!r}"
+                raise ValueError(msg)
+            return None
+        self._ensure_previously_set(dist, field)
+        return None
+
+    def _obtain_version(self, dist: "Distribution", package_dir: Mapping[str, str]):
+        # Since plugins can set version, let's silently skip if it cannot be obtained
+        if "version" in self.dynamic and "version" in self.dynamic_cfg:
+            return _expand.version(self._obtain(dist, "version", package_dir))
+        return None
+
+    def _obtain_readme(self, dist: "Distribution") -> Optional[Dict[str, str]]:
+        if "readme" in self.dynamic:
+            dynamic_cfg = self.dynamic_cfg
+            return {
+                "text": self._obtain(dist, "readme", {}),
+                "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
+            }
+        return None
+
+    def _obtain_entry_points(
+        self, dist: "Distribution", package_dir: Mapping[str, str]
+    ) -> Optional[Dict[str, dict]]:
+        fields = ("entry-points", "scripts", "gui-scripts")
+        if not any(field in self.dynamic for field in fields):
+            return None
+
+        text = self._obtain(dist, "entry-points", package_dir)
+        if text is None:
+            return None
+
+        groups = _expand.entry_points(text)
+        expanded = {"entry-points": groups}
+
+        def _set_scripts(field: str, group: str):
+            if group in groups:
+                value = groups.pop(group)
+                if field not in self.dynamic:
+                    msg = _WouldIgnoreField.message(field, value)
+                    warnings.warn(msg, _WouldIgnoreField)
+                # TODO: Don't set field when support for pyproject.toml stabilizes
+                #       instead raise an error as specified in PEP 621
+                expanded[field] = value
+
+        _set_scripts("scripts", "console_scripts")
+        _set_scripts("gui-scripts", "gui_scripts")
+
+        return expanded
+
+    def _obtain_classifiers(self, dist: "Distribution"):
+        if "classifiers" in self.dynamic:
+            value = self._obtain(dist, "classifiers", {})
+            if value:
+                return value.splitlines()
+        return None
 
-        process(setuptools_cfg, "data-files", data_files)
-        process(setuptools_cfg, "cmdclass", cmdclass)
-        _expand_all_dynamic(project_cfg, setuptools_cfg, package_dir, root_dir, ignore)
 
-    return config
-
-
-def _ensure_dist(
-    dist: Optional["Distribution"], project_cfg: dict, root_dir: _Path
-) -> "Distribution":
-    from setuptools.dist import Distribution
+@contextmanager
+def _ignore_errors(ignore_option_errors: bool):
+    if not ignore_option_errors:
+        yield
+        return
 
-    attrs = {"src_root": root_dir, "name": project_cfg.get("name", None)}
-    return dist or Distribution(attrs)
+    try:
+        yield
+    except Exception as ex:
+        _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
 
 
 class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):
@@ -253,134 +405,12 @@ def __exit__(self, exc_type, exc_value, traceback):
         return super().__exit__(exc_type, exc_value, traceback)
 
 
-def _expand_all_dynamic(
-    project_cfg: dict,
-    setuptools_cfg: dict,
-    package_dir: Mapping[str, str],
-    root_dir: _Path,
-    ignore_option_errors: bool,
-):
-    ignore = ignore_option_errors
-    dynamic_cfg = setuptools_cfg.get("dynamic", {})
-    pkg_dir = package_dir
-    special = (
-        "readme",
-        "version",
-        "entry-points",
-        "scripts",
-        "gui-scripts",
-        "classifiers",
-    )
-    # readme, version and entry-points need special handling
-    dynamic = project_cfg.get("dynamic", [])
-    regular_dynamic = (x for x in dynamic if x not in special)
-
-    for field in regular_dynamic:
-        value = _expand_dynamic(dynamic_cfg, field, pkg_dir, root_dir, ignore)
-        project_cfg[field] = value
-
-    if "version" in dynamic and "version" in dynamic_cfg:
-        version = _expand_dynamic(dynamic_cfg, "version", pkg_dir, root_dir, ignore)
-        project_cfg["version"] = _expand.version(version)
-
-    if "readme" in dynamic:
-        project_cfg["readme"] = _expand_readme(dynamic_cfg, root_dir, ignore)
-
-    if "entry-points" in dynamic:
-        field = "entry-points"
-        value = _expand_dynamic(dynamic_cfg, field, pkg_dir, root_dir, ignore)
-        project_cfg.update(_expand_entry_points(value, dynamic))
-
-    if "classifiers" in dynamic:
-        value = _expand_dynamic(dynamic_cfg, "classifiers", pkg_dir, root_dir, ignore)
-        project_cfg["classifiers"] = (value or "").splitlines()
-
-
-def _expand_dynamic(
-    dynamic_cfg: dict,
-    field: str,
-    package_dir: Mapping[str, str],
-    root_dir: _Path,
-    ignore_option_errors: bool,
-):
-    if field in dynamic_cfg:
-        directive = dynamic_cfg[field]
-        with _ignore_errors(ignore_option_errors):
-            if "file" in directive:
-                return _expand.read_files(directive["file"], root_dir)
-            if "attr" in directive:
-                return _expand.read_attr(directive["attr"], package_dir, root_dir)
-    elif not ignore_option_errors:
-        msg = f"Impossible to expand dynamic value of {field!r}. "
-        msg += f"No configuration found for `tool.setuptools.dynamic.{field}`"
-        raise OptionError(msg)
-    return None
-
-
-def _expand_readme(
-    dynamic_cfg: dict, root_dir: _Path, ignore_option_errors: bool
-) -> Dict[str, str]:
-    ignore = ignore_option_errors
-    return {
-        "text": _expand_dynamic(dynamic_cfg, "readme", {}, root_dir, ignore),
-        "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
-    }
-
-
-def _expand_entry_points(text: str, dynamic: set):
-    groups = _expand.entry_points(text)
-    expanded = {"entry-points": groups}
-    if "scripts" in dynamic and "console_scripts" in groups:
-        expanded["scripts"] = groups.pop("console_scripts")
-    if "gui-scripts" in dynamic and "gui_scripts" in groups:
-        expanded["gui-scripts"] = groups.pop("gui_scripts")
-    return expanded
-
-
-def _expand_packages(setuptools_cfg: dict, root_dir: _Path, ignore_option_errors=False):
-    packages = setuptools_cfg.get("packages")
-    if packages is None or isinstance(packages, (list, tuple)):
-        return
-
-    find = packages.get("find")
-    if isinstance(find, dict):
-        find["root_dir"] = root_dir
-        find["fill_package_dir"] = setuptools_cfg.setdefault("package-dir", {})
-        with _ignore_errors(ignore_option_errors):
-            setuptools_cfg["packages"] = _expand.find_packages(**find)
-
-
-def _process_field(
-    container: dict, field: str, fn: Callable, ignore_option_errors=False
-):
-    if field in container:
-        with _ignore_errors(ignore_option_errors):
-            container[field] = fn(container[field])
-
-
-def _canonic_package_data(setuptools_cfg, field="package-data"):
-    package_data = setuptools_cfg.get(field, {})
-    return _expand.canonic_package_data(package_data)
-
-
-@contextmanager
-def _ignore_errors(ignore_option_errors: bool):
-    if not ignore_option_errors:
-        yield
-        return
-
-    try:
-        yield
-    except Exception as ex:
-        _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
-
-
 class _ExperimentalProjectMetadata(UserWarning):
     """Explicitly inform users that `pyproject.toml` configuration is experimental"""
 
 
 class _InvalidFile(UserWarning):
-    """Inform users that the given `pyproject.toml` is experimental.
+    """Inform users that the given `pyproject.toml` is experimental:
     !!\n\n
     ############################
     # Invalid `pyproject.toml` #


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
@@ -14,6 +14,7 @@
 from setuptools.dist import Distribution
 from setuptools.config import setupcfg, pyprojecttoml
 from setuptools.config import expand
+from setuptools.config._apply_pyprojecttoml import _WouldIgnoreField
 
 
 EXAMPLES = (Path(__file__).parent / "setupcfg_examples.txt").read_text()
@@ -21,8 +22,8 @@
 DOWNLOAD_DIR = Path(__file__).parent / "downloads"
 
 
-def makedist(path):
-    return Distribution({"src_root": path})
+def makedist(path, **attrs):
+    return Distribution({"src_root": path, **attrs})
 
 
 @pytest.mark.parametrize("url", EXAMPLE_URLS)
@@ -205,6 +206,51 @@ def test_license_and_license_files(tmp_path):
     assert dist.metadata.license == "LicenseRef-Proprietary\n"
 
 
+class TestPresetField:
+    def pyproject(self, tmp_path, dynamic):
+        content = f"[project]\nname = 'proj'\ndynamic = {dynamic!r}\n"
+        if "version" not in dynamic:
+            content += "version = '42'\n"
+        file = tmp_path / "pyproject.toml"
+        file.write_text(content, encoding="utf-8")
+        return file
+
+    @pytest.mark.parametrize(
+        "attr, field, value",
+        [
+            ("install_requires", "dependencies", ["six"]),
+            ("classifiers", "classifiers", ["Private :: Classifier"]),
+        ]
+    )
+    def test_not_listed_in_dynamic(self, tmp_path, attr, field, value):
+        """For the time being we just warn if the user pre-set values (e.g. via
+        ``setup.py``) but do not include them in ``dynamic``.
+        """
+        pyproject = self.pyproject(tmp_path, [])
+        dist = makedist(tmp_path, **{attr: value})
+        msg = re.compile(f"defined outside of `pyproject.toml`:.*{field}", re.S)
+        with pytest.warns(_WouldIgnoreField, match=msg):
+            dist = pyprojecttoml.apply_configuration(dist, pyproject)
+
+        # TODO: Once support for pyproject.toml config stabilizes attr should be None
+        dist_value = getattr(dist, attr, None) or getattr(dist.metadata, attr, object())
+        assert dist_value == value
+
+    @pytest.mark.parametrize(
+        "attr, field, value",
+        [
+            ("install_requires", "dependencies", ["six"]),
+            ("classifiers", "classifiers", ["Private :: Classifier"]),
+        ]
+    )
+    def test_listed_in_dynamic(self, tmp_path, attr, field, value):
+        pyproject = self.pyproject(tmp_path, [field])
+        dist = makedist(tmp_path, **{attr: value})
+        dist = pyprojecttoml.apply_configuration(dist, pyproject)
+        dist_value = getattr(dist, attr, None) or getattr(dist.metadata, attr, object())
+        assert dist_value == value
+
+
 # --- Auxiliary Functions ---
 
 
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
@@ -1,4 +1,5 @@
 import logging
+import re
 from configparser import ConfigParser
 from inspect import cleandoc
 
@@ -6,6 +7,7 @@
 import tomli_w
 from path import Path as _Path
 
+from setuptools.config._apply_pyprojecttoml import _WouldIgnoreField
 from setuptools.config.pyprojecttoml import (
     read_configuration,
     expand_configuration,
@@ -171,31 +173,43 @@ def test_discovered_package_dir_with_attr_directive_in_config(tmp_path, pkg_root
 }
 
 
-def test_expand_entry_point(tmp_path):
-    entry_points = ConfigParser()
-    entry_points.read_dict(ENTRY_POINTS)
-    with open(tmp_path / "entry-points.txt", "w") as f:
-        entry_points.write(f)
-
-    tool = {"setuptools": {"dynamic": {"entry-points": {"file": "entry-points.txt"}}}}
-    project = {"dynamic": ["scripts", "gui-scripts", "entry-points"]}
-    pyproject = {"project": project, "tool": tool}
-    expanded = expand_configuration(pyproject, tmp_path)
-    expanded_project = expanded["project"]
-    assert len(expanded_project["scripts"]) == 1
-    assert expanded_project["scripts"]["a"] == "mod.a:func"
-    assert len(expanded_project["gui-scripts"]) == 1
-    assert expanded_project["gui-scripts"]["b"] == "mod.b:func"
-    assert len(expanded_project["entry-points"]) == 1
-    assert expanded_project["entry-points"]["other"]["c"] == "mod.c:func [extra]"
-
-    project = {"dynamic": ["entry-points"]}
-    pyproject = {"project": project, "tool": tool}
-    expanded = expand_configuration(pyproject, tmp_path)
-    expanded_project = expanded["project"]
-    assert len(expanded_project["entry-points"]) == 3
-    assert "scripts" not in expanded_project
-    assert "gui-scripts" not in expanded_project
+class TestEntryPoints:
+    def write_entry_points(self, tmp_path):
+        entry_points = ConfigParser()
+        entry_points.read_dict(ENTRY_POINTS)
+        with open(tmp_path / "entry-points.txt", "w") as f:
+            entry_points.write(f)
+
+    def pyproject(self, dynamic=None):
+        project = {"dynamic": dynamic or ["scripts", "gui-scripts", "entry-points"]}
+        tool = {"dynamic": {"entry-points": {"file": "entry-points.txt"}}}
+        return {"project": project, "tool": {"setuptools": tool}}
+
+    def test_all_listed_in_dynamic(self, tmp_path):
+        self.write_entry_points(tmp_path)
+        expanded = expand_configuration(self.pyproject(), tmp_path)
+        expanded_project = expanded["project"]
+        assert len(expanded_project["scripts"]) == 1
+        assert expanded_project["scripts"]["a"] == "mod.a:func"
+        assert len(expanded_project["gui-scripts"]) == 1
+        assert expanded_project["gui-scripts"]["b"] == "mod.b:func"
+        assert len(expanded_project["entry-points"]) == 1
+        assert expanded_project["entry-points"]["other"]["c"] == "mod.c:func [extra]"
+
+    @pytest.mark.parametrize("missing_dynamic", ("scripts", "gui-scripts"))
+    def test_scripts_not_listed_in_dynamic(self, tmp_path, missing_dynamic):
+        self.write_entry_points(tmp_path)
+        dynamic = {"scripts", "gui-scripts", "entry-points"} - {missing_dynamic}
+
+        msg = f"defined outside of `pyproject.toml`:.*{missing_dynamic}"
+        with pytest.warns(_WouldIgnoreField, match=re.compile(msg, re.S)):
+            expanded = expand_configuration(self.pyproject(dynamic), tmp_path)
+
+        expanded_project = expanded["project"]
+        assert dynamic < set(expanded_project)
+        assert len(expanded_project["entry-points"]) == 1
+        # TODO: Test the following when pyproject.toml support stabilizes:
+        # >>> assert missing_dynamic not in expanded_project
 
 
 class TestClassifiers:
@@ -236,7 +250,7 @@ def test_dynamic_without_config(self, tmp_path):
 
         pyproject = tmp_path / "pyproject.toml"
         pyproject.write_text(cleandoc(config))
-        with pytest.raises(OptionError, match="No configuration found"):
+        with pytest.raises(OptionError, match="No configuration .* .classifiers."):
             read_configuration(pyproject)
 
     def test_dynamic_without_file(self, tmp_path):
@@ -254,7 +268,7 @@ def test_dynamic_without_file(self, tmp_path):
         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"]
+        assert "classifiers" not in expanded["project"]
 
 
 @pytest.mark.parametrize(