Repository: scikit-build/scikit-build-core
Pull request #459 (2023-08-11 17:24:30)

feat: write metadata to file
Mentioned in #457.



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f4554a2..4b53cb4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -51,7 +51,7 @@ repos:
       - id: prettier
         types_or: [yaml, markdown, html, css, scss, javascript, json]
         args: [--prose-wrap=always]
-        exclude: "^tests"
+        exclude: "^tests|src/scikit_build_core/resources/scikit-build.schema.json"
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
     rev: v0.0.282
diff --git a/README.md b/README.md
index ae2ee1e..06a042e 100644
--- a/README.md
+++ b/README.md
@@ -228,6 +228,22 @@ install.components = []
 # Whether to strip the binaries. True for scikit-build-core 0.5+.
 install.strip = false
 
+# The path (relative to platlib) for the file to generate.
+generate[].path = ""
+
+# The template to use for the file. This includes string.Template style
+# placeholders for all the metadata. If empty, a template-path must be set.
+generate[].template = ""
+
+# The path to the template file. If empty, a template must be set.
+generate[].template-path = ""
+
+# The place to put the generated file. The "build" directory is useful for CMake
+# files, and the "install" directory is useful for Python files, usually. You
+# can also write directly to the "source" directory, will overwrite existing
+# files & remember to gitignore the file.
+generate[].location = "install"
+
 # List dynamic metadata fields and hook locations in this table.
 metadata = {}
 
diff --git a/docs/api/scikit_build_core.build.rst b/docs/api/scikit_build_core.build.rst
index 2c5844a..80df734 100644
--- a/docs/api/scikit_build_core.build.rst
+++ b/docs/api/scikit_build_core.build.rst
@@ -9,6 +9,14 @@ scikit\_build\_core.build package
 Submodules
 ----------
 
+scikit\_build\_core.build.generate module
+-----------------------------------------
+
+.. automodule:: scikit_build_core.build.generate
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 scikit\_build\_core.build.sdist module
 --------------------------------------
 
diff --git a/docs/configuration.md b/docs/configuration.md
index d09bfeb..d4f3422 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -376,7 +376,7 @@ default targets):
 
 ## Dynamic metadata
 
-Scikit-build-core 0.3.0 supports dynamic metadata with two built-in plugins.
+Scikit-build-core 0.3.0+ supports dynamic metadata with two built-in plugins.
 
 :::{warning}
 
@@ -470,6 +470,30 @@ regex for python files,
 
 :::
 
+### Writing metadata
+
+You can write out metadata to file(s) as well. Other info might become available
+here in the future, but currently it supports anything available as strings in
+metadata. (Note that arrays like this are only supported in TOML configuration.)
+
+```toml
+[[tool.scikit-build.generate]]
+path = "package/_version.py"
+template = '''
+version = "${version}"
+'''
+```
+
+`template` or `template-file` is required; this uses {class}`string.Template`
+formatting. There are three options for output location; `location = "install"`
+(the default) will go to the wheel, `location = "build"` will go to the CMake
+build directory, and `location = "source"` will write out to the source
+directory (be sure to .gitignore this file. It will automatically be added to
+your SDist includes. It will overwrite existing files).
+
+The path is generally relative to the base of the wheel / build dir / source
+dir, depending on which location you pick.
+
 ## Editable installs
 
 Experimental support for editable installs is provided, with some caveats and
diff --git a/src/scikit_build_core/_compat/typing.py b/src/scikit_build_core/_compat/typing.py
index 09d31a1..2394775 100644
--- a/src/scikit_build_core/_compat/typing.py
+++ b/src/scikit_build_core/_compat/typing.py
@@ -15,13 +15,18 @@ else:
 
 if sys.version_info < (3, 11):
     if typing.TYPE_CHECKING:
-        from typing_extensions import Self
+        from typing_extensions import Self, assert_never
     else:
         Self = object
+
+        def assert_never(_: typing.Any) -> None:
+            msg = "Expected code to be unreachable"
+            raise AssertionError(msg)
+
 else:
-    from typing import Self
+    from typing import Self, assert_never
 
-__all__ = ["Protocol", "Literal", "Self", "get_origin", "get_args"]
+__all__ = ["Protocol", "Literal", "Self", "get_origin", "get_args", "assert_never"]
 
 
 def __dir__() -> list[str]:
diff --git a/src/scikit_build_core/build/generate.py b/src/scikit_build_core/build/generate.py
new file mode 100644
index 0000000..9456f11
--- /dev/null
+++ b/src/scikit_build_core/build/generate.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+__all__ = ["generate_file_contents"]
+
+import dataclasses
+import string
+
+from pyproject_metadata import StandardMetadata
+
+from ..settings.skbuild_model import GenerateSettings
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+def generate_file_contents(gen: GenerateSettings, metadata: StandardMetadata) -> str:
+    """
+    Generate a file contents from a template. Input GeneratorSettings and
+    metadata. Metadata is available inside the template.
+    """
+
+    assert (
+        gen.template_path or gen.template
+    ), f"One of template or template-path must be set for {gen.path}"
+
+    if gen.template_path:
+        template = gen.template_path.read_text(encoding="utf-8")
+    else:
+        template = gen.template
+
+    return string.Template(template).substitute(dataclasses.asdict(metadata))
diff --git a/src/scikit_build_core/build/sdist.py b/src/scikit_build_core/build/sdist.py
index 3d6d568..5616f5f 100644
--- a/src/scikit_build_core/build/sdist.py
+++ b/src/scikit_build_core/build/sdist.py
@@ -18,6 +18,7 @@ from ..settings.metadata import get_standard_metadata
 from ..settings.skbuild_read_settings import SettingsReader
 from ._file_processor import each_unignored_file
 from ._init import setup_logging
+from .generate import generate_file_contents
 from .wheel import _build_wheel_impl
 
 __all__ = ["build_sdist"]
@@ -70,6 +71,22 @@ def normalize_tar_info(tar_info: tarfile.TarInfo) -> tarfile.TarInfo:
     return tar_info
 
 
+def add_bytes_to_tar(
+    tar: tarfile.TarFile, data: bytes, name: str, normalize: bool
+) -> None:
+    """
+    Write ``data`` bytes to ``name`` in a tarfile ``tar``. Normalize the info if
+    ``normalize`` is true.
+    """
+
+    tarinfo = tarfile.TarInfo(name)
+    if normalize:
+        tarinfo = normalize_tar_info(tarinfo)
+    with io.BytesIO(data) as bio:
+        tarinfo.size = bio.getbuffer().nbytes
+        tar.addfile(tarinfo, bio)
+
+
 def build_sdist(
     sdist_directory: str,
     config_settings: dict[str, list[str] | str] | None = None,
@@ -116,6 +133,12 @@ def build_sdist(
             None, config_settings, None, exit_after_config=True, editable=False
         )
 
+    for gen in settings.generate:
+        if gen.location == "source":
+            contents = generate_file_contents(gen, metadata)
+            gen.path.write_text(contents)
+            settings.sdist.include.append(str(gen.path))
+
     sdist_dir.mkdir(parents=True, exist_ok=True)
     with contextlib.ExitStack() as stack:
         gzip_container = stack.enter_context(
@@ -138,11 +161,6 @@ def build_sdist(
                 filter=normalize_tar_info if reproducible else lambda x: x,
             )
 
-        tarinfo = tarfile.TarInfo(name=f"{srcdirname}/PKG-INFO")
-        tarinfo.size = len(pkg_info)
-        if reproducible:
-            tarinfo = normalize_tar_info(tarinfo)
-        with io.BytesIO(pkg_info) as fileobj:
-            tar.addfile(tarinfo, fileobj)
+        add_bytes_to_tar(tar, pkg_info, f"{srcdirname}/PKG-INFO", reproducible)
 
     return filename
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index 6c68116..5d62b89 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -11,6 +11,7 @@ from pathlib import Path
 
 from .. import __version__
 from .._compat import tomllib
+from .._compat.typing import assert_never
 from .._logging import logger, rich_print
 from .._shutil import fix_win_37_all_permissions
 from ..builder.builder import Builder, archs_to_tags, get_archs
@@ -28,6 +29,7 @@ from ._pathutil import (
 )
 from ._scripts import process_script_dir
 from ._wheelfile import WheelWriter
+from .generate import generate_file_contents
 
 __all__ = ["_build_wheel_impl"]
 
@@ -167,6 +169,12 @@ def _build_wheel_impl(
                 "No license files found, set wheel.license-files to [] to suppress this warning"
             )
 
+        for gen in settings.generate:
+            if gen.location == "source":
+                contents = generate_file_contents(gen, metadata)
+                gen.path.write_text(contents)
+                settings.sdist.include.append(str(gen.path))
+
         config = CMaker(
             cmake,
             source_dir=settings.cmake.source_dir,
@@ -199,6 +207,19 @@ def _build_wheel_impl(
                 path.write_bytes(data)
             return WheelImplReturn(wheel_filename=dist_info.name)
 
+        for gen in settings.generate:
+            contents = generate_file_contents(gen, metadata)
+            if gen.location == "source":
+                continue
+            if gen.location == "build":
+                path = build_dir / gen.path
+            elif gen.location == "install":
+                path = install_dir / gen.path
+            else:
+                assert_never(gen.location)
+            path.parent.mkdir(parents=True, exist_ok=True)
+            path.write_text(contents, encoding="utf-8")
+
         rich_print("[green]***[/green] [bold]Configuring CMake...")
         defines: dict[str, str] = {}
         cache_entries: dict[str, str | Path] = {
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index bab6eb7..83c3cbf 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -82,7 +82,14 @@
       "additionalProperties": false,
       "properties": {
         "level": {
-          "enum": ["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
+          "enum": [
+            "NOTSET",
+            "DEBUG",
+            "INFO",
+            "WARNING",
+            "ERROR",
+            "CRITICAL"
+          ],
           "default": "WARNING",
           "description": "The logging level to display, \"DEBUG\", \"INFO\", \"WARNING\", and \"ERROR\" are possible options."
         }
@@ -169,7 +176,9 @@
       "additionalProperties": false,
       "properties": {
         "mode": {
-          "enum": ["redirect"],
+          "enum": [
+            "redirect"
+          ],
           "default": "redirect",
           "description": "Select the editable mode to use. Currently only \"redirect\" is supported."
         },
@@ -202,6 +211,71 @@
         }
       }
     },
+    "generate": {
+      "type": "array",
+      "items": {
+        "oneOf": [
+          {
+            "type": "object",
+            "additionalProperties": false,
+            "required": [
+              "path",
+              "template"
+            ],
+            "properties": {
+              "path": {
+                "type": "string",
+                "description": "The path (relative to platlib) for the file to generate.",
+                "minLength": 1
+              },
+              "template": {
+                "type": "string",
+                "description": "The template to use for the file. This includes string.Template style placeholders for all the metadata. If empty, a template-path must be set.",
+                "minLength": 1
+              },
+              "location": {
+                "enum": [
+                  "install",
+                  "build",
+                  "source"
+                ],
+                "default": "install",
+                "description": "The place to put the generated file. The \"build\" directory is useful for CMake files, and the \"install\" directory is useful for Python files, usually. You can also write directly to the \"source\" directory, will overwrite existing files & remember to gitignore the file."
+              }
+            }
+          },
+          {
+            "type": "object",
+            "additionalProperties": false,
+            "required": [
+              "path",
+              "template-path"
+            ],
+            "properties": {
+              "path": {
+                "type": "string",
+                "description": "The path (relative to platlib) for the file to generate.",
+                "minLength": 1
+              },
+              "template-path": {
+                "type": "string",
+                "description": "The path to the template file. If empty, a template must be set.",
+                "minLength": 1
+              },
+              "location": {
+                "enum": [
+                  "install",
+                  "build",
+                  "source"
+                ],
+                "default": "install",
+                "description": "The place to put the generated file. The \"build\" directory is useful for CMake files, and the \"install\" directory is useful for Python files, usually. You can also write directly to the \"source\" directory, will overwrite existing files & remember to gitignore the file."
+              }
+            }
+          }
+        ]
+      }
+    },
     "metadata": {
       "type": "object",
       "patternProperties": {
diff --git a/src/scikit_build_core/settings/documentation.py b/src/scikit_build_core/settings/documentation.py
index 4ff0f5a..2b81e77 100644
--- a/src/scikit_build_core/settings/documentation.py
+++ b/src/scikit_build_core/settings/documentation.py
@@ -10,6 +10,8 @@ from pathlib import Path
 
 from packaging.version import Version
 
+from .._compat.typing import get_args, get_origin
+
 __all__ = ["pull_docs"]
 
 
@@ -64,7 +66,13 @@ 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:
+        if get_origin(field.type) is list:
+            field_type = get_args(field.type)[0]
+            if dataclasses.is_dataclass(field_type):
+                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:
             default = repr(
                 str(field.default)
                 if isinstance(field.default, (Path, Version))
@@ -73,7 +81,7 @@ def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]:
         elif field.default_factory is not dataclasses.MISSING:
             default = repr(field.default_factory())
         else:
-            default = ""
+            default = '""'
 
         yield DCDoc(
             f"{prefix}{field.name}".replace("_", "-"),
diff --git a/src/scikit_build_core/settings/json_schema.py b/src/scikit_build_core/settings/json_schema.py
index f489387..637e5f8 100644
--- a/src/scikit_build_core/settings/json_schema.py
+++ b/src/scikit_build_core/settings/json_schema.py
@@ -26,6 +26,7 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
     assert dataclasses.is_dataclass(dclass)
     props = {}
     errs = []
+    required = []
     for field in dataclasses.fields(dclass):
         if dataclasses.is_dataclass(field.type):
             props[field.name] = to_json_schema(
@@ -34,7 +35,7 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
             continue
 
         try:
-            props[field.name] = convert_type(field.type)
+            props[field.name] = convert_type(field.type, normalize_keys=normalize_keys)
         except FailedConversion as err:
             if sys.version_info < (3, 11):
                 notes = "__notes__"  # set so linter's won't try to be clever
@@ -52,6 +53,12 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
                 else field.default
             )
 
+        if (
+            field.default_factory is dataclasses.MISSING
+            and field.default is dataclasses.MISSING
+        ):
+            required.append(field.name)
+
     if errs:
         msg = f"Failed Conversion to JSON Schema on {dclass.__name__}"
         raise ExceptionGroup(msg, errs)
@@ -63,10 +70,20 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
     if normalize_keys:
         props = {k.replace("_", "-"): v for k, v in props.items()}
 
+    if required:
+        return {
+            "type": "object",
+            "additionalProperties": False,
+            "required": required,
+            "properties": props,
+        }
+
     return {"type": "object", "additionalProperties": False, "properties": props}
 
 
-def convert_type(t: Any) -> dict[str, Any]:
+def convert_type(t: Any, *, normalize_keys: bool) -> dict[str, Any]:
+    if dataclasses.is_dataclass(t):
+        return to_json_schema(t, normalize_keys=normalize_keys)
     if t is str or t is Path or t is Version:
         return {"type": "string"}
     if t is bool:
@@ -75,18 +92,29 @@ def convert_type(t: Any) -> dict[str, Any]:
     args = get_args(t)
     if origin is list:
         assert len(args) == 1
-        return {"type": "array", "items": convert_type(args[0])}
+        return {
+            "type": "array",
+            "items": convert_type(args[0], normalize_keys=normalize_keys),
+        }
     if origin is dict:
         assert len(args) == 2
         assert args[0] is str
         if args[1] is Any:
             return {"type": "object"}
-        return {"type": "object", "patternProperties": {".+": convert_type(args[1])}}
+        return {
+            "type": "object",
+            "patternProperties": {
+                ".+": convert_type(args[1], normalize_keys=normalize_keys)
+            },
+        }
     if origin is Union:
         # Ignore optional
         if len(args) == 2 and any(a is type(None) for a in args):
-            return convert_type(next(iter(a for a in args if a is not type(None))))
-        return {"oneOf": [convert_type(a) for a in args]}
+            return convert_type(
+                next(iter(a for a in args if a is not type(None))),
+                normalize_keys=normalize_keys,
+            )
+        return {"oneOf": [convert_type(a, normalize_keys=normalize_keys) for a in args]}
     if origin is Literal:
         return {"enum": list(args)}
 
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index b998b77..76ce31b 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -15,6 +15,7 @@ __all__ = [
     "NinjaSettings",
     "SDistSettings",
     "ScikitBuildSettings",
+    "GenerateSettings",
     "WheelSettings",
 ]
 
@@ -206,6 +207,33 @@ class InstallSettings:
     """
 
 
+@dataclasses.dataclass
+class GenerateSettings:
+    path: Path
+    """
+    The path (relative to platlib) for the file to generate.
+    """
+
+    template: str = ""
+    """
+    The template to use for the file. This includes string.Template style
+    placeholders for all the metadata. If empty, a template-path must be set.
+    """
+
+    template_path: Optional[Path] = None
+    """
+    The path to the template file. If empty, a template must be set.
+    """
+
+    location: Literal["install", "build", "source"] = "install"
+    """
+    The place to put the generated file. The "build" directory is useful for
+    CMake files, and the "install" directory is useful for Python files,
+    usually. You can also write directly to the "source" directory, will
+    overwrite existing files & remember to gitignore the file.
+    """
+
+
 @dataclasses.dataclass
 class ScikitBuildSettings:
     cmake: CMakeSettings = dataclasses.field(default_factory=CMakeSettings)
@@ -216,6 +244,8 @@ class ScikitBuildSettings:
     backport: BackportSettings = dataclasses.field(default_factory=BackportSettings)
     editable: EditableSettings = dataclasses.field(default_factory=EditableSettings)
     install: InstallSettings = dataclasses.field(default_factory=InstallSettings)
+    generate: List[GenerateSettings] = dataclasses.field(default_factory=list)
+
     metadata: Dict[str, Dict[str, Any]] = dataclasses.field(default_factory=dict)
     """
     List dynamic metadata fields and hook locations in this table.
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index 257123b..f5fdd4d 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -117,6 +117,14 @@ class SettingsReader:
                 )
                 raise SystemExit(7)
 
+        for gen in self.settings.generate:
+            if not gen.template and not gen.template_path:
+                sys.stdout.flush()
+                rich_print(
+                    "[red][bold]ERROR:[/bold] template= or template-path= must be provided in generate"
+                )
+                raise SystemExit(7)
+
     @classmethod
     def from_file(
         cls,
diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py
index fe8d686..4bae10c 100644
--- a/src/scikit_build_core/settings/skbuild_schema.py
+++ b/src/scikit_build_core/settings/skbuild_schema.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import copy
 import json
 from typing import Any
 
@@ -19,13 +20,35 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
     from .json_schema import to_json_schema
     from .skbuild_model import ScikitBuildSettings
 
-    return {
+    schema = {
         "$schema": "http://json-schema.org/draft-07/schema",
         "$id": "https://github.com/scikit-build/scikit-build-core/blob/main/src/scikit_build_core/resources/scikit-build.schema.json",
         "description": "Scikit-build-core's settings.",
         **to_json_schema(ScikitBuildSettings, normalize_keys=True),
     }
 
+    # Manipulate a bit to get better validation
+    # This is making the generate's template or template-path required
+    generate = schema["properties"]["generate"]["items"]
+    for prop in generate["properties"].values():
+        if prop.get("type", "") == "string":
+            prop["minLength"] = 1
+    generate_tmpl = copy.deepcopy(generate)
+    generate_path = copy.deepcopy(generate)
+
+    generate_tmpl["required"] = ["path", "template"]
+    del generate_tmpl["properties"]["template-path"]
+    del generate_tmpl["properties"]["template"]["default"]
+
+    generate_path["required"] = ["path", "template-path"]
+    del generate_path["properties"]["template"]
+
+    schema["properties"]["generate"]["items"] = {
+        "oneOf": [generate_tmpl, generate_path]
+    }
+
+    return schema
+
 
 def get_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
     "Get the stored complete schema for scikit-build settings."
diff --git a/src/scikit_build_core/settings/sources.py b/src/scikit_build_core/settings/sources.py
index b969458..f5869b4 100644
--- a/src/scikit_build_core/settings/sources.py
+++ b/src/scikit_build_core/settings/sources.py
@@ -34,7 +34,7 @@ When setting up your dataclasses, these types are handled:
 - Any callable (`Path`, `Version`): Passed the string input.
 - ``Optional[T]``: Treated like T. Default should be None, since no input format supports None's.
 - ``Union[str, ...]``: Supports other input types in TOML form (bool currently). Otherwise a string.
-- ``List[T]``: A list of items. `;` separated supported in EnvVar/config forms.
+- ``List[T]``: A list of items. `;` separated supported in EnvVar/config forms. T can be a dataclass (TOML only).
 - ``Dict[str, T]``: A table of items. TOML supports a layer of nesting. Any is supported as an item type.
 - ``Literal[...]``: A list of strings, the result must be in the list.
 
@@ -212,6 +212,9 @@ class EnvSource:
     @classmethod
     def convert(cls, item: str, target: type[Any]) -> object:
         raw_target = _get_target_raw_type(target)
+        if dataclasses.is_dataclass(raw_target):
+            msg = f"Array of dataclasses are not supported in configuration settings ({raw_target})"
+            raise TypeError(msg)
         if raw_target == list:
             return [
                 cls.convert(i.strip(), _get_inner_type(target)) for i in item.split(";")
@@ -323,6 +326,9 @@ class ConfSource:
         cls, item: str | list[str] | dict[str, str], target: type[Any]
     ) -> object:
         raw_target = _get_target_raw_type(target)
+        if dataclasses.is_dataclass(raw_target):
+            msg = f"Array of dataclasses are not supported in configuration settings ({raw_target})"
+            raise TypeError(msg)
         if raw_target == list:
             if isinstance(item, list):
                 return [cls.convert(i, _get_inner_type(target)) for i in item]
@@ -404,6 +410,15 @@ class TOMLSource:
     @classmethod
     def convert(cls, item: Any, target: type[Any]) -> object:
         raw_target = _get_target_raw_type(target)
+        if dataclasses.is_dataclass(raw_target):
+            fields = dataclasses.fields(raw_target)
+            values = ((k.replace("-", "_"), v) for k, v in item.items())
+            return raw_target(
+                **{
+                    k: cls.convert(v, *[f.type for f in fields if f.name == k])
+                    for k, v in values
+                }
+            )
         if raw_target is list:
             if not isinstance(item, list):
                 msg = f"Expected {target}, got {type(item).__name__}"


Test patch:
diff --git a/tests/packages/sdist_config/.gitignore b/tests/packages/sdist_config/.gitignore
index 70533e3..7abdf7d 100644
--- a/tests/packages/sdist_config/.gitignore
+++ b/tests/packages/sdist_config/.gitignore
@@ -1,1 +1,2 @@
 /pybind11
+overwrite.cmake
diff --git a/tests/packages/sdist_config/CMakeLists.txt b/tests/packages/sdist_config/CMakeLists.txt
index 1a59670..9a45113 100644
--- a/tests/packages/sdist_config/CMakeLists.txt
+++ b/tests/packages/sdist_config/CMakeLists.txt
@@ -1,5 +1,8 @@
 cmake_minimum_required(VERSION 3.15...3.27)
-project(sdist_config LANGUAGES CXX)
+project(
+  sdist_config
+  LANGUAGES CXX
+  VERSION ${SKBUILD_PROJECT_VERSION})
 
 include(FetchContent)
 
@@ -20,3 +23,14 @@ FetchContent_MakeAvailable(pybind11)
 
 pybind11_add_module(sdist_config main.cpp)
 install(TARGETS sdist_config DESTINATION .)
+
+# Generation test
+include("${CMAKE_CURRENT_BINARY_DIR}/output.cmake")
+if(NOT "${MY_VERSION}" STREQUAL "${PROJECT_VERSION}")
+  message(FATAL_ERROR "Version mismatch: ${MY_VERSION} != ${PROJECT_VERSION}")
+endif()
+
+include("${CMAKE_CURRENT_SOURCE_DIR}/overwrite.cmake")
+if(NOT "${MY_NAME}" STREQUAL "${SKBUILD_PROJECT_NAME}")
+  message(FATAL_ERROR "Name mismatch: ${MY_NAME} != ${SKBUILD_PROJECT_NAME}")
+endif()
diff --git a/tests/packages/sdist_config/input.cmake b/tests/packages/sdist_config/input.cmake
new file mode 100644
index 0000000..d80edc2
--- /dev/null
+++ b/tests/packages/sdist_config/input.cmake
@@ -0,0 +1,1 @@
+set(MY_VERSION "${version}")
diff --git a/tests/packages/sdist_config/pyproject.toml b/tests/packages/sdist_config/pyproject.toml
index f3ae48b..6e3037a 100644
--- a/tests/packages/sdist_config/pyproject.toml
+++ b/tests/packages/sdist_config/pyproject.toml
@@ -16,3 +16,21 @@ sdist.include = [
 wheel.license-files = []
 wheel.packages = []
 cmake.define.FETCHCONTENT_QUIET = false
+
+[[tool.scikit-build.generate]]
+path = "output.cmake"
+location = "build"
+template-path = "input.cmake"
+
+[[tool.scikit-build.generate]]
+path = "output.py"
+template = """
+version = "${version}"
+"""
+
+[[tool.scikit-build.generate]]
+path = "overwrite.cmake"
+location = "source"
+template = """
+set(MY_NAME "${name}")
+"""
diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py
index 7ef6771..1541161 100644
--- a/tests/test_json_schema.py
+++ b/tests/test_json_schema.py
@@ -8,51 +8,54 @@ from scikit_build_core.settings.json_schema import FailedConversion, convert_typ
 
 
 def test_convert_str():
-    assert convert_type(str) == {"type": "string"}
+    assert convert_type(str, normalize_keys=False) == {"type": "string"}
 
 
 def test_convert_str_or_bool():
-    assert convert_type(Union[str, bool]) == {
+    assert convert_type(Union[str, bool], normalize_keys=False) == {
         "oneOf": [{"type": "string"}, {"type": "boolean"}]
     }
 
 
 def test_convert_optional_str():
-    assert convert_type(Optional[str]) == {"type": "string"}
+    assert convert_type(Optional[str], normalize_keys=False) == {"type": "string"}
 
 
 def test_convert_path():
-    assert convert_type(Path) == {"type": "string"}
+    assert convert_type(Path, normalize_keys=False) == {"type": "string"}
 
 
 def test_convert_version():
-    assert convert_type(Version) == {"type": "string"}
+    assert convert_type(Version, normalize_keys=False) == {"type": "string"}
 
 
 def test_convert_list():
-    assert convert_type(List[str]) == {"type": "array", "items": {"type": "string"}}
-    assert convert_type(List[Union[str, bool]]) == {
+    assert convert_type(List[str], normalize_keys=False) == {
+        "type": "array",
+        "items": {"type": "string"},
+    }
+    assert convert_type(List[Union[str, bool]], normalize_keys=False) == {
         "type": "array",
         "items": {"oneOf": [{"type": "string"}, {"type": "boolean"}]},
     }
 
 
 def test_convert_dict():
-    assert convert_type(Dict[str, str]) == {
+    assert convert_type(Dict[str, str], normalize_keys=False) == {
         "type": "object",
         "patternProperties": {".+": {"type": "string"}},
     }
-    assert convert_type(Dict[str, Dict[str, str]]) == {
+    assert convert_type(Dict[str, Dict[str, str]], normalize_keys=False) == {
         "type": "object",
         "patternProperties": {
             ".+": {"type": "object", "patternProperties": {".+": {"type": "string"}}}
         },
     }
-    assert convert_type(Dict[str, Any]) == {
+    assert convert_type(Dict[str, Any], normalize_keys=False) == {
         "type": "object",
     }
 
 
 def test_convert_invalid():
     with pytest.raises(FailedConversion):
-        convert_type(object)
+        convert_type(object, normalize_keys=False)
diff --git a/tests/test_pyproject_pep518.py b/tests/test_pyproject_pep518.py
index d44fe9c..2c9e853 100644
--- a/tests/test_pyproject_pep518.py
+++ b/tests/test_pyproject_pep518.py
@@ -9,6 +9,14 @@ from pathlib import Path
 import pytest
 
 
+@pytest.fixture()
+def cleanup_overwrite():
+    overwrite = Path("overwrite.cmake")
+    yield overwrite
+    if overwrite.exists():
+        overwrite.unlink()
+
+
 @pytest.mark.network()
 @pytest.mark.integration()
 def test_pep518_sdist(isolated, package_simple_pyproject_ext):
@@ -54,7 +62,9 @@ def test_pep518_sdist(isolated, package_simple_pyproject_ext):
 @pytest.mark.configure()
 @pytest.mark.integration()
 @pytest.mark.usefixtures("package_sdist_config")
-def test_pep518_sdist_with_cmake_config(isolated):
+def test_pep518_sdist_with_cmake_config(isolated, cleanup_overwrite):
+    cleanup_overwrite.write_text("set(MY_VERSION fiddlesticks)")
+
     correct_metadata = textwrap.dedent(
         """\
         Metadata-Version: 2.1
@@ -77,6 +87,8 @@ def test_pep518_sdist_with_cmake_config(isolated):
                 "pyproject.toml",
                 "main.cpp",
                 "PKG-INFO",
+                "overwrite.cmake",
+                ".gitignore",
             )
         }
         assert sum("pybind11" in x for x in file_names) >= 10
@@ -85,6 +97,8 @@ def test_pep518_sdist_with_cmake_config(isolated):
         pkg_info_contents = pkg_info.read().decode()
         assert correct_metadata == pkg_info_contents
 
+    assert cleanup_overwrite.is_file()
+
 
 @pytest.mark.network()
 @pytest.mark.compile()
@@ -94,7 +108,9 @@ def test_pep518_sdist_with_cmake_config(isolated):
 @pytest.mark.parametrize(
     "build_args", [(), ("--wheel",)], ids=["sdist_to_wheel", "wheel_directly"]
 )
-def test_pep518_wheel_sdist_with_cmake_config(isolated, build_args, capfd):
+def test_pep518_wheel_sdist_with_cmake_config(
+    isolated, build_args, capfd, cleanup_overwrite
+):
     isolated.install("build[virtualenv]")
     isolated.module(
         "build",
@@ -116,9 +132,9 @@ def test_pep518_wheel_sdist_with_cmake_config(isolated, build_args, capfd):
             p = zipfile.Path(f)
             file_names = [p.name for p in p.iterdir()]
 
-        assert len(file_names) == 2
-        assert "sdist_config-0.1.0.dist-info" in file_names
+        assert len(file_names) == 3
         file_names.remove("sdist_config-0.1.0.dist-info")
+        file_names.remove("output.py")
         (so_file,) = file_names
 
         assert so_file.startswith("sdist_config")
@@ -129,6 +145,11 @@ def test_pep518_wheel_sdist_with_cmake_config(isolated, build_args, capfd):
     life = isolated.execute("import sdist_config; print(sdist_config.life())")
     assert life == "42"
 
+    version = isolated.execute("import output; print(output.version)")
+    assert version == "0.1.0"
+
+    assert cleanup_overwrite.is_file()
+
 
 @pytest.mark.compile()
 @pytest.mark.configure()
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 87a099e..23291a2 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -45,6 +45,9 @@ def test_valid_schemas_files(filepath: Path) -> None:
         {"minimum-version": 0.3},
         {"random": "not valid"},
         {"logging": {"level": "POODLE"}},
+        {"generate": [{"path": "CMakeLists.txt"}]},
+        {"generate": [{"path": "me.py", "template": "hi", "template-path": "hello"}]},
+        {"generate": [{"path": "me.py", "template": ""}]},
     ],
 )
 def test_invalid_schemas(addition: dict[str, Any]) -> None:
@@ -66,3 +69,29 @@ def test_invalid_schemas(addition: dict[str, Any]) -> None:
     validator = api.Validator()
     with pytest.raises(fastjsonschema.JsonSchemaValueException):
         validator(example)
+
+
+@pytest.mark.parametrize(
+    "addition",
+    [
+        {"generate": [{"path": "CMakeLists.txt", "template": "hi"}]},
+        {"generate": [{"path": "me.py", "template-path": "hello"}]},
+    ],
+)
+def test_valid_schemas(addition: dict[str, Any]) -> None:
+    api = pytest.importorskip("validate_pyproject.api")
+
+    example_toml = """\
+    [project]
+    name = "myproj"
+    version = "0"
+
+    [tool.scikit-build]
+    minimum-version = "0.3"
+    """
+
+    example = tomllib.loads(example_toml)
+    example["tool"]["scikit-build"].update(**addition)
+
+    validator = api.Validator()
+    validator(example)
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 8e1e7c7..d04887c 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -578,3 +578,80 @@ def test_literal_failure_not_contained_optional():
         except ExceptionGroup as e:
             assert len(e.exceptions) == 1  # noqa: PT017
             raise e.exceptions[0] from None
+
+
+@dataclasses.dataclass
+class ArraySetting:
+    required: str
+    optional: Optional[str] = None
+
+
+@dataclasses.dataclass
+class ArraySettings:
+    array: List[ArraySetting] = dataclasses.field(default_factory=list)
+
+
+def test_empty_array():
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={}),
+        TOMLSource(settings={}),
+    )
+
+    settings = sources.convert_target(ArraySettings)
+
+    assert settings.array == []
+
+
+def test_toml_required():
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={}),
+        TOMLSource(settings={"array": [{"optional": "2"}]}),
+    )
+
+    with pytest.raises(ExceptionGroup):
+        sources.convert_target(ArraySettings)
+
+
+def test_toml_array():
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={}),
+        TOMLSource(
+            settings={
+                "array": [{"required": "one"}, {"required": "two", "optional": "2"}]
+            }
+        ),
+    )
+
+    settings = sources.convert_target(ArraySettings)
+
+    assert settings.array == [ArraySetting("one"), ArraySetting("two", "2")]
+
+
+def test_env_array_error(monkeypatch: pytest.MonkeyPatch):
+    monkeypatch.setenv("SKBUILD_ARRAY", "one")
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={}),
+        TOMLSource(settings={}),
+    )
+
+    with pytest.raises(ExceptionGroup):
+        sources.convert_target(ArraySettings)
+
+
+def test_config_array_error():
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(
+            settings={
+                "array": [{"required": "one"}, {"required": "two", "optional": "2"}]  # type: ignore[list-item]
+            }
+        ),
+        TOMLSource(settings={}),
+    )
+
+    with pytest.raises(ExceptionGroup):
+        sources.convert_target(ArraySettings)
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 52b7ea5..68c18e0 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -11,6 +11,7 @@ import pytest
 from packaging.version import Version
 
 import scikit_build_core.settings.skbuild_read_settings
+from scikit_build_core.settings.skbuild_model import GenerateSettings
 from scikit_build_core.settings.skbuild_read_settings import SettingsReader
 
 
@@ -58,6 +59,7 @@ def test_skbuild_settings_default(tmp_path: Path):
     assert settings.editable.verbose
     assert settings.install.components == []
     assert settings.install.strip
+    assert settings.generate == []
 
 
 def test_skbuild_settings_envvar(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
@@ -250,6 +252,13 @@ def test_skbuild_settings_pyproject_toml(
             editable.verbose = false
             install.components = ["a", "b", "c"]
             install.strip = true
+            [[tool.scikit-build.generate]]
+            path = "a/b/c"
+            template = "hello"
+            [[tool.scikit-build.generate]]
+            path = "d/e/f"
+            template-path = "g/h/i"
+            location = "build"
             """
         ),
         encoding="utf-8",
@@ -290,6 +299,12 @@ def test_skbuild_settings_pyproject_toml(
     assert not settings.editable.verbose
     assert settings.install.components == ["a", "b", "c"]
     assert settings.install.strip
+    assert settings.generate == [
+        GenerateSettings(path=Path("a/b/c"), template="hello", location="install"),
+        GenerateSettings(
+            path=Path("d/e/f"), template_path=Path("g/h/i"), location="build"
+        ),
+    ]
 
 
 def test_skbuild_settings_pyproject_toml_broken(
@@ -327,7 +342,7 @@ def test_skbuild_settings_pyproject_toml_broken(
         == """\
       ERROR: Unrecognized options in pyproject.toml:
         tool.scikit-build.cmake.minimum-verison -> Did you mean: tool.scikit-build.cmake.minimum-version, tool.scikit-build.minimum-version, tool.scikit-build.ninja.minimum-version?
-        tool.scikit-build.logger -> Did you mean: tool.scikit-build.logging, tool.scikit-build.wheel, tool.scikit-build.cmake?
+        tool.scikit-build.logger -> Did you mean: tool.scikit-build.logging, tool.scikit-build.generate, tool.scikit-build.wheel?
       """.split()
     )