Repository: scikit-build/scikit-build-core
Pull request #1051 (2025-04-23 13:42:57)

doc: Add a field metadata to the setting fields 
A few preparations for #999

henryiii: Oh, interesting, I considered this before, but I was looking at annotations, this might be better. I'll try to review soon, working on the partially dynamic metadata PEP proposal currently.

Patch:
diff --git a/docs/api/scikit_build_core.metadata.rst b/docs/api/scikit_build_core.metadata.rst
index 7073d5e..da1713a 100644
--- a/docs/api/scikit_build_core.metadata.rst
+++ b/docs/api/scikit_build_core.metadata.rst
@@ -32,3 +32,11 @@ scikit\_build\_core.metadata.setuptools\_scm module
    :members:
    :show-inheritance:
    :undoc-members:
+
+scikit\_build\_core.metadata.template module
+--------------------------------------------
+
+.. automodule:: scikit_build_core.metadata.template
+   :members:
+   :show-inheritance:
+   :undoc-members:
diff --git a/docs/configuration/dynamic.md b/docs/configuration/dynamic.md
index f46d44b..1be8bc1 100644
--- a/docs/configuration/dynamic.md
+++ b/docs/configuration/dynamic.md
@@ -52,7 +52,7 @@ dynamic_version()
 project(MyPackage VERSION ${PROJECT_VERSION})
 ```
 
-## `version`: Regex
+## Regex
 
 If you want to pull a string-valued expression (usually version) from an
 existing file, you can the integrated `regex` plugin to pull the information.
@@ -112,6 +112,30 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
 # tool.hatch.metadata.hooks.fancy-pypi-readme options here
 ```
 
+```{versionchanged} 0.11.2
+
+The version number feature now works.
+```
+
+## Template
+
+You can access other metadata fields and produce templated outputs.
+
+```toml
+[tool.scikit-build.metadata.optional-dependencies]
+provider = "scikit_build_core.metadata.template"
+result = {"dev" = ["{project[name]}=={project[version]}"]}
+```
+
+You can use `project` to access the current metadata values. You can reference
+other dynamic metadata fields, and they will be computed before this one. You
+can use `result` to specify the output. The result must match the type of the
+metadata field you are writing to.
+
+```{versionadded} 0.11.2
+
+```
+
 ## `build-system.requires`: Scikit-build-core's `build.requires`
 
 If you need to inject and manipulate additional `build-system.requires`, you can
diff --git a/src/scikit_build_core/build/metadata.py b/src/scikit_build_core/build/metadata.py
index a35dcb6..ec82601 100644
--- a/src/scikit_build_core/build/metadata.py
+++ b/src/scikit_build_core/build/metadata.py
@@ -13,7 +13,7 @@ from .._vendor.pyproject_metadata import (
     extras_build_system,
     extras_top_level,
 )
-from ..settings._load_provider import load_dynamic_metadata
+from ..builder._load_provider import process_dynamic_metadata
 
 if TYPE_CHECKING:
     from collections.abc import Mapping
@@ -34,23 +34,16 @@ if sys.version_info < (3, 11):
     errors.ExceptionGroup = ExceptionGroup  # type: ignore[misc, assignment]
 
 
-# If pyproject-metadata eventually supports updates, this can be simplified
 def get_standard_metadata(
     pyproject_dict: Mapping[str, Any],
     settings: ScikitBuildSettings,
 ) -> StandardMetadata:
-    new_pyproject_dict = copy.deepcopy(pyproject_dict)
+    new_pyproject_dict = copy.deepcopy(dict(pyproject_dict))
 
     # Handle any dynamic metadata
-    for field, provider, config in load_dynamic_metadata(settings.metadata):
-        if provider is None:
-            msg = f"{field} is missing provider"
-            raise KeyError(msg)
-        if field not in pyproject_dict.get("project", {}).get("dynamic", []):
-            msg = f"{field} is not in project.dynamic"
-            raise KeyError(msg)
-        new_pyproject_dict["project"][field] = provider.dynamic_metadata(field, config)
-        new_pyproject_dict["project"]["dynamic"].remove(field)
+    new_pyproject_dict["project"] = process_dynamic_metadata(
+        new_pyproject_dict["project"], settings.metadata
+    )
 
     if settings.strict_config:
         extra_keys_top = extras_top_level(new_pyproject_dict)
diff --git a/src/scikit_build_core/builder/_load_provider.py b/src/scikit_build_core/builder/_load_provider.py
new file mode 100644
index 0000000..cc6862f
--- /dev/null
+++ b/src/scikit_build_core/builder/_load_provider.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import dataclasses
+import importlib
+import inspect
+import sys
+from collections.abc import Iterator, Mapping
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable
+
+if TYPE_CHECKING:
+    from collections.abc import Generator, Iterable
+
+    StrMapping = Mapping[str, Any]
+else:
+    StrMapping = Mapping
+
+from ..metadata import _ALL_FIELDS
+
+__all__ = ["load_provider", "process_dynamic_metadata"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+@runtime_checkable
+class DynamicMetadataProtocol(Protocol):
+    def dynamic_metadata(
+        self,
+        fields: Iterable[str],
+        settings: dict[str, Any],
+        project: Mapping[str, Any],
+    ) -> dict[str, Any]: ...
+
+
+@runtime_checkable
+class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
+    def get_requires_for_dynamic_metadata(
+        self, settings: dict[str, Any]
+    ) -> list[str]: ...
+
+
+@runtime_checkable
+class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
+    def dynamic_wheel(self, field: str, settings: Mapping[str, Any]) -> bool: ...
+
+
+DMProtocols = Union[
+    DynamicMetadataProtocol,
+    DynamicMetadataRequirementsProtocol,
+    DynamicMetadataWheelProtocol,
+]
+
+
+def load_provider(
+    provider: str,
+    provider_path: str | None = None,
+) -> DMProtocols:
+    if provider_path is None:
+        return importlib.import_module(provider)
+
+    if not Path(provider_path).is_dir():
+        msg = "provider-path must be an existing directory"
+        raise AssertionError(msg)
+
+    try:
+        sys.path.insert(0, provider_path)
+        return importlib.import_module(provider)
+    finally:
+        sys.path.pop(0)
+
+
+def _load_dynamic_metadata(
+    metadata: Mapping[str, Mapping[str, str]],
+) -> Generator[tuple[str, DMProtocols, dict[str, Any]], None, None]:
+    for field, orig_config in metadata.items():
+        if "provider" not in orig_config:
+            msg = "Missing provider in dynamic metadata"
+            raise KeyError(msg)
+
+        if field not in _ALL_FIELDS:
+            msg = f"{field} is not a valid field"
+            raise KeyError(msg)
+        config = dict(orig_config)
+        provider = config.pop("provider")
+        provider_path = config.pop("provider-path", None)
+        loaded_provider = load_provider(provider, provider_path)
+        yield field, loaded_provider, config
+
+
+@dataclasses.dataclass
+class DynamicPyProject(StrMapping):
+    settings: dict[str, dict[str, Any]]
+    project: dict[str, Any]
+    providers: dict[str, DMProtocols]
+
+    def __getitem__(self, key: str) -> Any:
+        # Try to get the settings from either the static file or dynamic metadata provider
+        if key in self.project:
+            return self.project[key]
+
+        # Check if we are in a loop, i.e. something else is already requesting
+        # this key while trying to get another key
+        if key not in self.providers:
+            dep_type = "missing" if key in self.settings else "circular"
+            msg = f"Encountered a {dep_type} dependency at {key}"
+            raise ValueError(msg)
+
+        provider = self.providers.pop(key)
+        sig = inspect.signature(provider.dynamic_metadata)
+        if len(sig.parameters) < 3:
+            # Backcompat for dynamic_metadata without metadata dict
+            self.project[key] = provider.dynamic_metadata(  # type: ignore[call-arg]
+                key, self.settings[key]
+            )
+        else:
+            self.project[key] = provider.dynamic_metadata(key, self.settings[key], self)
+        self.project["dynamic"].remove(key)
+
+        return self.project[key]
+
+    def __iter__(self) -> Iterator[str]:
+        # Iterate over the keys of the static settings
+        yield from self.project
+
+        # Iterate over the keys of the dynamic metadata providers
+        yield from self.providers
+
+    def __len__(self) -> int:
+        return len(self.project) + len(self.providers)
+
+    def __contains__(self, key: object) -> bool:
+        return key in self.project or key in self.providers
+
+
+def process_dynamic_metadata(
+    project: Mapping[str, Any],
+    metadata: Mapping[str, Mapping[str, Any]],
+) -> dict[str, Any]:
+    initial = {f: (p, c) for (f, p, c) in _load_dynamic_metadata(metadata)}
+
+    settings = DynamicPyProject(
+        settings={f: c for f, (_, c) in initial.items()},
+        project=dict(project),
+        providers={k: v for k, (v, _) in initial.items()},
+    )
+
+    return dict(settings)
diff --git a/src/scikit_build_core/builder/get_requires.py b/src/scikit_build_core/builder/get_requires.py
index 492aa1e..aff5c2c 100644
--- a/src/scikit_build_core/builder/get_requires.py
+++ b/src/scikit_build_core/builder/get_requires.py
@@ -19,8 +19,8 @@ from ..program_search import (
     get_ninja_programs,
 )
 from ..resources import resources
-from ..settings._load_provider import load_provider
 from ..settings.skbuild_read_settings import SettingsReader
+from ._load_provider import load_provider
 
 if TYPE_CHECKING:
     from collections.abc import Generator, Mapping
diff --git a/src/scikit_build_core/metadata/__init__.py b/src/scikit_build_core/metadata/__init__.py
index bdec2fc..4ec51f4 100644
--- a/src/scikit_build_core/metadata/__init__.py
+++ b/src/scikit_build_core/metadata/__init__.py
@@ -1,3 +1,87 @@
 from __future__ import annotations
 
-__all__: list[str] = []
+import typing
+
+if typing.TYPE_CHECKING:
+    from collections.abc import Callable
+
+
+__all__: list[str] = ["_ALL_FIELDS", "_process_dynamic_metadata"]
+
+
+# Name is not dynamically settable, so not in this list
+_STR_FIELDS = frozenset(
+    [
+        "version",
+        "description",
+        "requires-python",
+        "license",
+    ]
+)
+
+# Dynamic is not dynamically settable, so not in this list
+_LIST_STR_FIELDS = frozenset(
+    [
+        "classifiers",
+        "keywords",
+        "dependencies",
+        "license_files",
+    ]
+)
+
+_DICT_STR_FIELDS = frozenset(
+    [
+        "urls",
+        "authors",
+        "maintainers",
+    ]
+)
+
+# "dynamic" and "name" can't be set or requested
+_ALL_FIELDS = (
+    _STR_FIELDS
+    | _LIST_STR_FIELDS
+    | _DICT_STR_FIELDS
+    | frozenset(
+        [
+            "optional-dependencies",
+            "readme",
+        ]
+    )
+)
+
+T = typing.TypeVar("T", bound="str | list[str] | dict[str, str] | dict[str, list[str]]")
+
+
+def _process_dynamic_metadata(field: str, action: Callable[[str], str], result: T) -> T:
+    """
+    Helper function for processing the an action on the various possible metadata fields.
+    """
+
+    if field in _STR_FIELDS:
+        if not isinstance(result, str):
+            msg = f"Field {field!r} must be a string"
+            raise RuntimeError(msg)
+        return action(result)  # type: ignore[return-value]
+    if field in _LIST_STR_FIELDS:
+        if not (isinstance(result, list) and all(isinstance(r, str) for r in result)):
+            msg = f"Field {field!r} must be a list of strings"
+            raise RuntimeError(msg)
+        return [action(r) for r in result]  # type: ignore[return-value]
+    if field in _DICT_STR_FIELDS | {"readme"}:
+        if not isinstance(result, dict) or not all(
+            isinstance(v, str) for v in result.values()
+        ):
+            msg = f"Field {field!r} must be a dictionary of strings"
+            raise RuntimeError(msg)
+        return {k: action(v) for k, v in result.items()}  # type: ignore[return-value]
+    if field == "optional-dependencies":
+        if not isinstance(result, dict) or not all(
+            isinstance(v, list) for v in result.values()
+        ):
+            msg = "Field 'optional-dependencies' must be a dictionary of lists"
+            raise RuntimeError(msg)
+        return {k: [action(r) for r in v] for k, v in result.items()}  # type: ignore[return-value]
+
+    msg = f"Unsupported field {field!r} for action"
+    raise RuntimeError(msg)
diff --git a/src/scikit_build_core/metadata/fancy_pypi_readme.py b/src/scikit_build_core/metadata/fancy_pypi_readme.py
index 1187f3a..f78f5ab 100644
--- a/src/scikit_build_core/metadata/fancy_pypi_readme.py
+++ b/src/scikit_build_core/metadata/fancy_pypi_readme.py
@@ -1,10 +1,17 @@
 from __future__ import annotations
 
 from pathlib import Path
+from typing import TYPE_CHECKING, Any
 
 from .._compat import tomllib
 
-__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"]
+if TYPE_CHECKING:
+    from collections.abc import Mapping
+
+__all__ = [
+    "dynamic_metadata",
+    "get_requires_for_dynamic_metadata",
+]
 
 
 def __dir__() -> list[str]:
@@ -13,7 +20,8 @@ def __dir__() -> list[str]:
 
 def dynamic_metadata(
     field: str,
-    settings: dict[str, list[str] | str] | None = None,
+    settings: dict[str, list[str] | str],
+    project: Mapping[str, Any],
 ) -> str | dict[str, str]:
     from hatch_fancy_pypi_readme._builder import build_text
     from hatch_fancy_pypi_readme._config import load_and_validate_config
@@ -36,7 +44,9 @@ def dynamic_metadata(
     if hasattr(config, "substitutions"):
         try:
             # We don't have access to the version at this point
-            text = build_text(config.fragments, config.substitutions, "")
+            text = build_text(
+                config.fragments, config.substitutions, version=project["version"]
+            )
         except TypeError:
             # Version 23.2.0 and before don't have a version field
             # pylint: disable-next=no-value-for-parameter
diff --git a/src/scikit_build_core/metadata/regex.py b/src/scikit_build_core/metadata/regex.py
index 5f1213a..787406a 100644
--- a/src/scikit_build_core/metadata/regex.py
+++ b/src/scikit_build_core/metadata/regex.py
@@ -1,9 +1,12 @@
 from __future__ import annotations
 
+import functools
 import re
 from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
+from . import _process_dynamic_metadata
+
 if TYPE_CHECKING:
     from collections.abc import Mapping
 
@@ -17,14 +20,18 @@ def __dir__() -> list[str]:
 KEYS = {"input", "regex", "result", "remove"}
 
 
+def _process(match: re.Match[str], remove: str, result: str) -> str:
+    retval = result.format(*match.groups(), **match.groupdict())
+    if remove:
+        retval = re.sub(remove, "", retval)
+    return retval
+
+
 def dynamic_metadata(
     field: str,
     settings: Mapping[str, Any],
 ) -> str:
     # Input validation
-    if field not in {"version", "description", "requires-python"}:
-        msg = "Only string fields supported by this plugin"
-        raise RuntimeError(msg)
     if settings.keys() > KEYS:
         msg = f"Only {KEYS} settings allowed by this plugin"
         raise RuntimeError(msg)
@@ -55,7 +62,6 @@ def dynamic_metadata(
         msg = f"Couldn't find {regex!r} in {input_filename}"
         raise RuntimeError(msg)
 
-    retval = result.format(*match.groups(), **match.groupdict())
-    if remove:
-        retval = re.sub(remove, "", retval)
-    return retval
+    return _process_dynamic_metadata(
+        field, functools.partial(_process, match, remove), result
+    )
diff --git a/src/scikit_build_core/metadata/template.py b/src/scikit_build_core/metadata/template.py
new file mode 100644
index 0000000..749d1b5
--- /dev/null
+++ b/src/scikit_build_core/metadata/template.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+    from collections.abc import Mapping
+
+from . import _process_dynamic_metadata
+
+__all__ = ["dynamic_metadata"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+KEYS = {"result"}
+
+
+def dynamic_metadata(
+    field: str,
+    settings: Mapping[str, str | list[str] | dict[str, str] | dict[str, list[str]]],
+    project: Mapping[str, Any],
+) -> str | list[str] | dict[str, str] | dict[str, list[str]]:
+    if settings.keys() > KEYS:
+        msg = f"Only {KEYS} settings allowed by this plugin"
+        raise RuntimeError(msg)
+
+    if "result" not in settings:
+        msg = "Must contain the 'result' setting with a template substitution"
+        raise RuntimeError(msg)
+
+    result = settings["result"]
+
+    return _process_dynamic_metadata(
+        field,
+        lambda r: r.format(project=project),
+        result,
+    )
diff --git a/src/scikit_build_core/settings/_load_provider.py b/src/scikit_build_core/settings/_load_provider.py
deleted file mode 100644
index d992e42..0000000
--- a/src/scikit_build_core/settings/_load_provider.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from __future__ import annotations
-
-import importlib
-import sys
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Protocol, Union
-
-if TYPE_CHECKING:
-    from collections.abc import Generator, Iterable, Mapping
-
-__all__ = ["load_dynamic_metadata", "load_provider"]
-
-
-def __dir__() -> list[str]:
-    return __all__
-
-
-class DynamicMetadataProtocol(Protocol):
-    def dynamic_metadata(
-        self, fields: Iterable[str], settings: dict[str, Any]
-    ) -> dict[str, Any]: ...
-
-
-class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
-    def get_requires_for_dynamic_metadata(
-        self, settings: dict[str, Any]
-    ) -> list[str]: ...
-
-
-class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
-    def dynamic_wheel(
-        self, field: str, settings: Mapping[str, Any] | None = None
-    ) -> bool: ...
-
-
-class DynamicMetadataRequirementsWheelProtocol(
-    DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
-): ...
-
-
-DMProtocols = Union[
-    DynamicMetadataProtocol,
-    DynamicMetadataRequirementsProtocol,
-    DynamicMetadataWheelProtocol,
-    DynamicMetadataRequirementsWheelProtocol,
-]
-
-
-def load_provider(
-    provider: str,
-    provider_path: str | None = None,
-) -> DMProtocols:
-    if provider_path is None:
-        return importlib.import_module(provider)
-
-    if not Path(provider_path).is_dir():
-        msg = "provider-path must be an existing directory"
-        raise AssertionError(msg)
-
-    try:
-        sys.path.insert(0, provider_path)
-        return importlib.import_module(provider)
-    finally:
-        sys.path.pop(0)
-
-
-def load_dynamic_metadata(
-    metadata: Mapping[str, Mapping[str, str]],
-) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]:
-    for field, orig_config in metadata.items():
-        if "provider" in orig_config:
-            config = dict(orig_config)
-            provider = config.pop("provider")
-            provider_path = config.pop("provider-path", None)
-            yield field, load_provider(provider, provider_path), config
-        else:
-            yield field, None, dict(orig_config)
diff --git a/src/scikit_build_core/settings/documentation.py b/src/scikit_build_core/settings/documentation.py
index cd6fc1c..06dadb3 100644
--- a/src/scikit_build_core/settings/documentation.py
+++ b/src/scikit_build_core/settings/documentation.py
@@ -4,17 +4,19 @@ import ast
 import dataclasses
 import inspect
 import textwrap
+import typing
 from pathlib import Path
-from typing import TYPE_CHECKING
 
 from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
+from .. import __version__
 from .._compat.typing import get_args, get_origin
 
-if TYPE_CHECKING:
+if typing.TYPE_CHECKING:
     from collections.abc import Generator
 
+
 __all__ = ["pull_docs"]
 
 
@@ -22,6 +24,9 @@ def __dir__() -> list[str]:
     return __all__
 
 
+version_display = ".".join(__version__.split(".")[:2])
+
+
 def _get_value(value: ast.expr) -> str:
     assert isinstance(value, ast.Constant)
     assert isinstance(value.value, str)
@@ -45,17 +50,22 @@ def pull_docs(dc: type[object]) -> dict[str, str]:
     }
 
 
-@dataclasses.dataclass
+@dataclasses.dataclass(frozen=True)
 class DCDoc:
     name: str
     default: str
     docs: str
+    deprecated: bool = False
 
     def __str__(self) -> str:
         docs = "\n".join(f"# {s}" for s in textwrap.wrap(self.docs, width=78))
         return f"{docs}\n{self.name} = {self.default}\n"
 
 
+def sanitize_default_field(text: str) -> str:
+    return text.replace("'", '"').replace("True", "true").replace("False", "false")
+
+
 def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]:
     """
     Makes documentation for a dataclass.
@@ -75,7 +85,12 @@ def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]:
                 yield from mk_docs(field_type, prefix=f"{prefix}{field.name}[].")
                 continue
 
-        if field.default is not dataclasses.MISSING and field.default is not None:
+        if default_before_format := field.metadata.get("display_default", None):
+            assert isinstance(default_before_format, str)
+            default = default_before_format.format(
+                version=version_display,
+            )
+        elif field.default is not dataclasses.MISSING and field.default is not None:
             default = repr(
                 str(field.default)
                 if isinstance(field.default, (Path, Version, SpecifierSet))
@@ -87,7 +102,8 @@ def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]:
             default = '""'
 
         yield DCDoc(
-            f"{prefix}{field.name}".replace("_", "-"),
-            default.replace("'", '"').replace("True", "true").replace("False", "false"),
-            docs[field.name],
+            name=f"{prefix}{field.name}".replace("_", "-"),
+            default=sanitize_default_field(default),
+            docs=docs[field.name],
+            deprecated=field.metadata.get("deprecated", False),
         )
diff --git a/src/scikit_build_core/settings/skbuild_docs.py b/src/scikit_build_core/settings/skbuild_docs.py
index 65a03c3..01723d3 100644
--- a/src/scikit_build_core/settings/skbuild_docs.py
+++ b/src/scikit_build_core/settings/skbuild_docs.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-from .. import __version__
 from .documentation import mk_docs
 from .skbuild_model import ScikitBuildSettings
 
@@ -11,24 +10,13 @@ def __dir__() -> list[str]:
     return __all__
 
 
-version = ".".join(__version__.split(".")[:2])
-
-INV = {"cmake.minimum-version", "ninja.minimum-version"}
-
-
 def mk_skbuild_docs() -> str:
     """
     Makes documentation for the skbuild model.
     """
-    items = [x for x in mk_docs(ScikitBuildSettings) if x.name not in INV]
-    for item in items:
-        if item.name == "minimum-version":
-            item.default = f'"{version}"  # current version'
-        if item.name == "install.strip":
-            item.default = "true"
-        if item.name == "wheel.packages":
-            item.default = '["src/<package>", "python/<package>", "<package>"]'
-    return "\n".join(str(item) for item in items)
+    return "\n".join(
+        str(item) for item in mk_docs(ScikitBuildSettings) if not item.deprecated
+    )
 
 
 if __name__ == "__main__":
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index adc92aa..d25a4a8 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -1,6 +1,6 @@
 import dataclasses
 from pathlib import Path
-from typing import Any, Dict, List, Literal, Optional, Union
+from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
 
 from packaging.specifiers import SpecifierSet
 from packaging.version import Version
@@ -21,6 +21,7 @@ __all__ = [
     "SDistSettings",
     "ScikitBuildSettings",
     "SearchSettings",
+    "SettingsFieldMetadata",
     "WheelSettings",
 ]
 
@@ -29,6 +30,11 @@ def __dir__() -> List[str]:
     return __all__
 
 
+class SettingsFieldMetadata(TypedDict, total=False):
+    display_default: Optional[str]
+    deprecated: bool
+
+
 class CMakeSettingsDefine(str):
     """
     A str subtype for automatically normalizing bool and list values
@@ -53,7 +59,9 @@ class CMakeSettingsDefine(str):
 
 @dataclasses.dataclass
 class CMakeSettings:
-    minimum_version: Optional[Version] = None
+    minimum_version: Optional[Version] = dataclasses.field(
+        default=None, metadata=SettingsFieldMetadata(deprecated=True)
+    )
     """
     DEPRECATED in 0.8; use version instead.
     """
@@ -114,7 +122,9 @@ class SearchSettings:
 
 @dataclasses.dataclass
 class NinjaSettings:
-    minimum_version: Optional[Version] = None
+    minimum_version: Optional[Version] = dataclasses.field(
+        default=None, metadata=SettingsFieldMetadata(deprecated=True)
+    )
     """
     DEPRECATED in 0.8; use version instead.
     """
@@ -174,7 +184,12 @@ class SDistSettings:
 
 @dataclasses.dataclass
 class WheelSettings:
-    packages: Optional[Union[List[str], Dict[str, str]]] = None
+    packages: Optional[Union[List[str], Dict[str, str]]] = dataclasses.field(
+        default=None,
+        metadata=SettingsFieldMetadata(
+            display_default='["src/<package>", "python/<package>", "<package>"]'
+        ),
+    )
     """
     A list of packages to auto-copy into the wheel. If this is not set, it will
     default to the first of ``src/<package>``, ``python/<package>``, or
@@ -300,7 +315,9 @@ class InstallSettings:
     The components to install. If empty, all default components are installed.
     """
 
-    strip: Optional[bool] = None
+    strip: Optional[bool] = dataclasses.field(
+        default=None, metadata=SettingsFieldMetadata(display_default="true")
+    )
     """
     Whether to strip the binaries. True for release builds on scikit-build-core
     0.5+ (0.5-0.10.5 also incorrectly set this for debug builds).
@@ -382,7 +399,12 @@ class ScikitBuildSettings:
     Enable early previews of features not finalized yet.
     """
 
-    minimum_version: Optional[Version] = None
+    minimum_version: Optional[Version] = dataclasses.field(
+        default=None,
+        metadata=SettingsFieldMetadata(
+            display_default='"{version}"  # current version'
+        ),
+    )
     """
     If set, this will provide a method for backward compatibility.
     """
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index a3bd624..ecfb998 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -92,12 +92,18 @@ def _handle_move(
     after: T,
     minimum_version: Version | None,
     introduced_in: Version,
+    *,
+    static: bool,
 ) -> T:
     """
     Backward_compat for moving names around. The default must be false-like.
     """
-
-    if after and minimum_version is not None and minimum_version < introduced_in:
+    if (
+        static
+        and after
+        and minimum_version is not None
+        and minimum_version < introduced_in
+    ):
         rich_error(
             f"Cannot set {after_name} if minimum-version is set to less than {introduced_in} (which is where it was introduced)"
         )
@@ -112,6 +118,8 @@ def _handle_move(
         )
 
     if before is not None and after:
+        if not static:
+            return after
         rich_error(f"Cannot set {before_name} and {after_name} at the same time")
 
     if before is None:
@@ -190,7 +198,7 @@ class SettingsReader:
             k: v for k, v in config_settings.items() if not k.startswith("skbuild.")
         }
         self.sources = SourceChain(
-            EnvSource("SKBUILD"),
+            EnvSource("SKBUILD", env=env),
             ConfSource("skbuild", settings=prefixed, verify=verify_conf),
             ConfSource(settings=remaining, verify=verify_conf),
             *toml_srcs,
@@ -198,6 +206,10 @@ class SettingsReader:
         )
         self.settings = self.sources.convert_target(ScikitBuildSettings)
 
+        static_settings = SourceChain(
+            *toml_srcs, prefixes=["tool", "scikit-build"]
+        ).convert_target(ScikitBuildSettings)
+
         if self.settings.minimum_version:
             current_version = Version(__version__)
             minimum_version = self.settings.minimum_version
@@ -285,6 +297,8 @@ class SettingsReader:
             self.settings.build.verbose,
             self.settings.minimum_version,
             Version("0.10"),
+            static=static_settings.cmake.verbose == self.settings.cmake.verbose
+            and static_settings.build.verbose == self.settings.build.verbose,
         )
         self.settings.build.targets = _handle_move(
             "cmake.targets",
@@ -293,6 +307,8 @@ class SettingsReader:
             self.settings.build.targets,
             self.settings.minimum_version,
             Version("0.10"),
+            static=static_settings.cmake.targets == self.settings.cmake.targets
+            and static_settings.build.targets == self.settings.build.targets,
         )
 
     def unrecognized_options(self) -> Generator[str, None, None]:


Test patch:
diff --git a/tests/packages/dynamic_metadata/plugin_project.toml b/tests/packages/dynamic_metadata/plugin_project.toml
index d460feb..974acc4 100644
--- a/tests/packages/dynamic_metadata/plugin_project.toml
+++ b/tests/packages/dynamic_metadata/plugin_project.toml
@@ -4,12 +4,16 @@ build-backend = "scikit_build_core.build"
 
 [project]
 name = "fancy"
-dynamic = ["readme", "version"]
+dynamic = ["readme", "version", "optional-dependencies"]
 
 [tool.scikit-build.metadata]
 version.provider = "scikit_build_core.metadata.setuptools_scm"
 readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
 
+[tool.scikit-build.metadata.optional-dependencies]
+provider = "scikit_build_core.metadata.template"
+result = {"dev" = ["{project[name]}=={project[version]}"]}
+
 [tool.setuptools_scm]
 
 [tool.hatch.metadata.hooks.fancy-pypi-readme]
@@ -19,4 +23,4 @@ content-type = "text/x-rst"
 text = "Fragment #1"
 
 [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
-text = "Fragment #2"
+text = "Fragment #2 -- $HFPR_VERSION"
diff --git a/tests/test_dynamic_metadata.py b/tests/test_dynamic_metadata.py
index 3599707..9038edc 100644
--- a/tests/test_dynamic_metadata.py
+++ b/tests/test_dynamic_metadata.py
@@ -10,6 +10,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
 import pytest
+from packaging.requirements import Requirement
 from packaging.version import Version
 
 from scikit_build_core._compat import tomllib
@@ -145,7 +146,7 @@ def test_plugin_metadata():
 
     assert str(metadata.version) == "0.1.0"
     assert metadata.readme == pyproject_metadata.Readme(
-        "Fragment #1Fragment #2", None, "text/x-rst"
+        "Fragment #1Fragment #2 -- 0.1.0", None, "text/x-rst"
     )
 
     assert set(GetRequires().dynamic_metadata()) == {
@@ -153,9 +154,14 @@ def test_plugin_metadata():
         "setuptools-scm",
     }
 
+    assert metadata.optional_dependencies == {"dev": [Requirement("fancy==0.1.0")]}
+
 
 @pytest.mark.usefixtures("package_dynamic_metadata")
 def test_faulty_metadata():
+    reason_msg = "install hatch-fancy-pypi-readme to test the dynamic metadata plugins"
+    pytest.importorskip("hatch_fancy_pypi_readme", reason=reason_msg)
+
     with Path("faulty_project.toml").open("rb") as ft:
         pyproject = tomllib.load(ft)
     settings_reader = SettingsReader(pyproject, {}, state="metadata_wheel")
@@ -274,8 +280,6 @@ def test_regex(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
 def test_regex_errors() -> None:
     with pytest.raises(RuntimeError):
         regex.dynamic_metadata("version", {})
-    with pytest.raises(RuntimeError, match="Only string fields supported"):
-        regex.dynamic_metadata("author", {"input": "x", "regex": "x"})
 
 
 def test_multipart_regex(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 88ed4a6..5da9199 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -624,27 +624,6 @@ def test_backcompat_cmake_build_env(tmp_path: Path, monkeypatch: pytest.MonkeyPa
     assert settings_reader.settings.build.targets == ["a", "b"]
 
 
-def test_backcompat_cmake_build_both_specified(
-    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-):
-    monkeypatch.setattr(
-        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
-    )
-    pyproject_toml = tmp_path / "pyproject.toml"
-    pyproject_toml.write_text(
-        textwrap.dedent(
-            """\
-            [tool.scikit-build]
-            cmake.verbose = true
-            """
-        ),
-        encoding="utf-8",
-    )
-
-    with pytest.raises(SystemExit):
-        SettingsReader.from_file(pyproject_toml, {"build.verbose": "1"})
-
-
 def test_auto_minimum_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
     monkeypatch.setattr(
         scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
@@ -813,3 +792,135 @@ def test_skbuild_settings_cmake_define_list():
         "NESTED_LIST": r"Apple;Lemon\;Lime;Banana",
         "ONE_LEVEL_LIST": "Foo;Bar;ExceptionallyLargeListEntryThatWouldOverflowTheLine;Baz",
     }
+
+
+def test_skbuild_override_renamed(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [tool.scikit-build]
+            minimum-version = "0.7"
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {"build.verbose": "1"})
+    assert settings_reader.settings.build.verbose
+
+    settings_reader = SettingsReader.from_file(
+        pyproject_toml, env={"SKBUILD_BUILD_VERBOSE": "TRUE"}
+    )
+    assert settings_reader.settings.build.verbose
+
+
+def test_skbuild_override_renamed_fail(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [tool.scikit-build]
+            minimum-version = "0.7"
+            build.verbose = true
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    with pytest.raises(SystemExit):
+        SettingsReader.from_file(pyproject_toml)
+
+
+def test_skbuild_override_static(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [tool.scikit-build]
+            minimum-version = "0.7"
+            build.verbose = true
+            """
+        ),
+        encoding="utf-8",
+    )
+    with pytest.raises(SystemExit):
+        SettingsReader.from_file(pyproject_toml)
+
+
+def test_backcompat_cmake_build_both_specified(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [tool.scikit-build]
+            cmake.verbose = true
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {"build.verbose": "1"})
+    assert settings_reader.settings.build.verbose
+
+
+def test_backcompat_cmake_build_both_specified_static(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [tool.scikit-build]
+            build.verbose = true
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {"cmake.verbose": "1"})
+    assert settings_reader.settings.build.verbose
+
+
+def test_backcompat_cmake_build_targets_both_specified(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [tool.scikit-build]
+            cmake.targets = ["a", "b"]
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {"build.targets": "c;d"})
+    assert settings_reader.settings.build.targets == ["c", "d"]