Repository: scikit-build/scikit-build-core
Pull request #447 (2023-07-31 22:33:30)

feat: add a schema for validate-pyproject
Adding a schema, generation from our dataclass-based config system, and the expected plugin.


henryiii: CC @abravalheri if you'd like to take a look. :)

Patch:
diff --git a/docs/api/scikit_build_core.settings.rst b/docs/api/scikit_build_core.settings.rst
index 1b1b40b..5a125bf 100644
--- a/docs/api/scikit_build_core.settings.rst
+++ b/docs/api/scikit_build_core.settings.rst
@@ -9,6 +9,22 @@ scikit\_build\_core.settings package
 Submodules
 ----------
 
+scikit\_build\_core.settings.documentation module
+-------------------------------------------------
+
+.. automodule:: scikit_build_core.settings.documentation
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+scikit\_build\_core.settings.json\_schema module
+------------------------------------------------
+
+.. automodule:: scikit_build_core.settings.json_schema
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 scikit\_build\_core.settings.metadata module
 --------------------------------------------
 
@@ -33,6 +49,14 @@ scikit\_build\_core.settings.skbuild\_read\_settings module
    :undoc-members:
    :show-inheritance:
 
+scikit\_build\_core.settings.skbuild\_schema module
+---------------------------------------------------
+
+.. automodule:: scikit_build_core.settings.skbuild_schema
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 scikit\_build\_core.settings.sources module
 -------------------------------------------
 
diff --git a/docs/conf.py b/docs/conf.py
index fd0a1b9..a1ba3f9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -69,6 +69,7 @@ extensions = [
     "sphinx_copybutton",
     "sphinx_inline_tabs",
     "conftabs",
+    "sphinx-jsonschema",
 ]
 
 # Add any paths that contain templates here, relative to this directory.
diff --git a/docs/configuration.md b/docs/configuration.md
index 7f31239..e67cc43 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -90,6 +90,16 @@ probably keep in sync with your build-system requirements.
 minimum-version = "0.2"
 ```
 
+:::{warning}
+
+The following behaviors are affected by `minimum-version`:
+
+- `minimum-version` 0.5+ (or unset) provides the original name in metadata and
+  properly normalized SDist names.
+- `minimum-version` 0.5+ (or unset) strips binaries by default.
+
+:::
+
 ## CMake and Ninja minimum versions
 
 You can select a different minimum version for CMake and Ninja.
@@ -452,3 +462,11 @@ only be used if you enable them:
 [tool.scikit-build]
 experimental = true
 ```
+
+## Full schema
+
+The full schema for the `tool.scikit-build` table is below:
+
+```{jsonschema} ../src/scikit_build_core/resources/scikit-build.schema.json
+
+```
diff --git a/docs/examples/downstream/nanobind_example/pyproject.toml b/docs/examples/downstream/nanobind_example/pyproject.toml
index 27035ba..f10c6b6 100644
--- a/docs/examples/downstream/nanobind_example/pyproject.toml
+++ b/docs/examples/downstream/nanobind_example/pyproject.toml
@@ -12,7 +12,7 @@ authors = [
     { name = "Wenzel Jakob", email = "wenzel.jakob@epfl.ch" },
 ]
 classifiers = [
-    "License :: BSD",
+    "License :: OSI Approved :: BSD License",
 ]
 
 [project.urls]
diff --git a/noxfile.py b/noxfile.py
index cef823b..861a6e7 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -60,6 +60,26 @@ def _run_tests(
     session.run("pytest", *run_args, *posargs, env=env)
 
 
+@nox.session(reuse_venv=True)
+def generate_schema(session: nox.Session) -> None:
+    session.install("-e.")
+    schema = session.run(
+        "python", "-m", "scikit_build_core.settings.skbuild_schema", silent=True
+    )
+    DIR.joinpath("src/scikit_build_core/resources/scikit-build.schema.json").write_text(schema)  # type: ignore[arg-type]
+
+
+@nox.session
+def validate_schemas(session: nox.Session) -> None:
+    session.install("-e.", "validate-pyproject")
+
+    docs_examples = Path("docs/examples").glob("**/pyproject.toml")
+    tests_packages = Path("tests/packages").glob("**/pyproject.toml")
+    paths = [*docs_examples, *tests_packages]
+    # map currently required to make mypy happy, future nox version should fix
+    session.run("validate-pyproject", *map(str, paths))
+
+
 @nox.session
 def tests(session: nox.Session) -> None:
     """
diff --git a/pyproject.toml b/pyproject.toml
index 868d5d7..9d868d7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -88,6 +88,7 @@ docs = [
     "sphinx >=4.0",
     "sphinx-copybutton",
     "sphinx-inline-tabs",
+    "sphinx-jsonschema",
 ]
 
 [project.urls]
@@ -102,6 +103,7 @@ Issues = "https://github.com/scikit-build/scikit-build-core/issues"
 "distutils.setup_keywords".cmake_source_dir = "scikit_build_core.setuptools.build_cmake:cmake_source_dir"
 "distutils.setup_keywords".cmake_args = "scikit_build_core.setuptools.build_cmake:cmake_args"
 "setuptools.finalize_distribution_options".scikit_build_entry = "scikit_build_core.setuptools.build_cmake:finalize_distribution_options"
+"validate_pyproject.tool_schema".scikit-build = "scikit_build_core.settings.skbuild_schema:get_skbuild_schema"
 
 [tool.hatch]
 version.source = "vcs"
diff --git a/src/scikit_build_core/build/sdist.py b/src/scikit_build_core/build/sdist.py
index 63a7a60..11bedec 100644
--- a/src/scikit_build_core/build/sdist.py
+++ b/src/scikit_build_core/build/sdist.py
@@ -8,7 +8,12 @@ import os
 import tarfile
 from pathlib import Path
 
+from packaging.utils import canonicalize_name
+from packaging.version import Version
+
+from .. import __version__
 from .._compat import tomllib
+from .._logging import rich_print
 from ..settings.metadata import get_standard_metadata
 from ..settings.skbuild_read_settings import SettingsReader
 from ._file_processor import each_unignored_file
@@ -68,6 +73,11 @@ def build_sdist(
     sdist_directory: str,
     config_settings: dict[str, list[str] | str] | None = None,
 ) -> str:
+    rich_print(
+        f"[green]***[/green] [bold][green]scikit-build-core {__version__}[/green]",
+        "[red](sdist)[/red]",
+    )
+
     with Path("pyproject.toml").open("rb") as f:
         pyproject = tomllib.load(f)
 
@@ -87,7 +97,17 @@ def build_sdist(
     # https://github.com/FFY00/python-pyproject-metadata/pull/49
     pkg_info = bytes(copy.deepcopy(metadata).as_rfc822())
 
-    srcdirname = f"{metadata.name}-{metadata.version}"
+    # Only normalize SDist name if 0.5+ is requested for backwards compat
+    should_normalize_name = settings.minimum_version is None or Version(
+        settings.minimum_version
+    ) >= Version("0.5")
+
+    sdist_name = (
+        canonicalize_name(metadata.name).replace("-", "_")
+        if should_normalize_name
+        else metadata.name
+    )
+    srcdirname = f"{sdist_name}-{metadata.version}"
     filename = f"{srcdirname}.tar.gz"
 
     sdist_dir.mkdir(parents=True, exist_ok=True)
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
new file mode 100644
index 0000000..6895d09
--- /dev/null
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -0,0 +1,221 @@
+{
+  "$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.",
+  "type": "object",
+  "additionalProperties": false,
+  "properties": {
+    "cmake": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "minimum-version": {
+          "type": "string",
+          "default": "3.15",
+          "description": "The minimum version of CMake to use. If CMake is older than this, it will be upgraded via PyPI if possible. An empty string will disable this check."
+        },
+        "args": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "A list of args to pass to CMake when configuring the project."
+        },
+        "define": {
+          "type": "object",
+          "patternProperties": {
+            ".+": {
+              "type": "string"
+            }
+          },
+          "description": "A table of defines to pass to CMake when configuring the project. Additive."
+        },
+        "verbose": {
+          "type": "boolean",
+          "default": false,
+          "description": "Verbose printout when building"
+        },
+        "build-type": {
+          "type": "string",
+          "default": "Release",
+          "description": "The build type to use when building the project. Valid options are: \"Debug\", \"Release\", \"RelWithDebInfo\", \"MinSizeRel\", \"\", etc."
+        },
+        "source-dir": {
+          "type": "string",
+          "default": "",
+          "description": "The source directory to use when building the project. Currently only affects the native builder (not the setuptools plugin)."
+        },
+        "targets": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "The build targets to use when building the project. Empty builds the default target."
+        }
+      }
+    },
+    "ninja": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "minimum-version": {
+          "type": "string",
+          "default": "1.5",
+          "description": "The minimum version of Ninja to use. If Ninja is older than this, it will be upgraded via PyPI if possible. An empty string will disable this check."
+        },
+        "make-fallback": {
+          "type": "boolean",
+          "default": true,
+          "description": "If make is present, do not add ninja if missing."
+        }
+      }
+    },
+    "logging": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "level": {
+          "type": "string",
+          "default": "WARNING",
+          "description": "The logging level to display."
+        }
+      }
+    },
+    "sdist": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "include": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Files to include in the SDist even if they are skipped by default."
+        },
+        "exclude": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Files to exclude from the SDist even if they are included by default."
+        },
+        "reproducible": {
+          "type": "boolean",
+          "default": true,
+          "description": "If set to True, try to build a reproducible distribution. ``SOURCE_DATE_EPOCH`` will be used for timestamps, or a fixed value if not set."
+        }
+      }
+    },
+    "wheel": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "packages": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "A list of packages to auto-copy into the wheel. If this is None, it will default to the first of ``src/<package>`` or ``<package>`` if they exist. The prefix(s) will be stripped from the package name inside the wheel."
+        },
+        "py-api": {
+          "type": "string",
+          "default": "",
+          "description": "The Python tags. The default (empty string) will use the default Python version. You can also set this to \"cp37\" to enable the CPython 3.7+ Stable ABI / Limited API (only on CPython and if the version is sufficient, otherwise this has no effect). Or you can set it to \"py3\" or \"py2.py3\" to ignore Python ABI compatibility. For the stable ABI, the CMake variable SKBUILD_SOABI will be set to abi3 on Unix-like systems (empty on Windows). FindPython doesn't have a way to target python3.dll instead of python3N.dll, so this is harder to use on Windows. The ABI tag is inferred from this tag."
+        },
+        "expand-macos-universal-tags": {
+          "type": "boolean",
+          "default": false,
+          "description": "Fill out extra tags that are not required. This adds \"x86_64\" and \"arm64\" to the list of platforms when \"universal2\" is used, which helps older Pip's (before 21.0.1) find the correct wheel."
+        },
+        "install-dir": {
+          "type": "string",
+          "default": "",
+          "description": "The install directory for the wheel. This is relative to the platlib root. EXPERIMENTAL: An absolute path will be one level higher than the platlib root, giving access to \"/platlib\", \"/data\", \"/headers\", and \"/scripts\"."
+        },
+        "license-files": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "A list of license files to include in the wheel. Supports glob patterns."
+        }
+      }
+    },
+    "backport": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "find-python": {
+          "type": "string",
+          "default": "3.26.1",
+          "description": "If CMake is less than this value, backport a copy of FindPython. Set to 0 disable this, or the empty string."
+        }
+      }
+    },
+    "metadata": {
+      "type": "object",
+      "patternProperties": {
+        ".+": {
+          "type": "object"
+        }
+      }
+    },
+    "editable": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "mode": {
+          "type": "string",
+          "default": "redirect",
+          "description": "Select the editable mode to use. Currently only \"redirect\" is supported."
+        },
+        "verbose": {
+          "type": "boolean",
+          "default": true,
+          "description": "Turn on verbose output for the editable mode rebuilds."
+        },
+        "rebuild": {
+          "type": "boolean",
+          "default": false,
+          "description": "Rebuild the project when the package is imported. The build-directory must be set."
+        }
+      }
+    },
+    "install": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "components": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "The components to install. If empty, the default is used."
+        },
+        "strip": {
+          "type": "boolean",
+          "description": "Whether to strip the binaries. True for scikit-build-core 0.5+."
+        }
+      }
+    },
+    "strict-config": {
+      "type": "boolean",
+      "default": true,
+      "description": "Strictly check all config options. If False, warnings will be printed for unknown options. If True, an error will be raised."
+    },
+    "experimental": {
+      "type": "boolean",
+      "default": false,
+      "description": "Enable early previews of features not finalized yet."
+    },
+    "minimum-version": {
+      "type": "string",
+      "description": "If set, this will provide a method for backward compatibility."
+    },
+    "build-dir": {
+      "type": "string",
+      "default": "",
+      "description": "The build directory. Defaults to a temporary directory, but can be set."
+    }
+  }
+}
diff --git a/src/scikit_build_core/settings/documentation.py b/src/scikit_build_core/settings/documentation.py
new file mode 100644
index 0000000..5073463
--- /dev/null
+++ b/src/scikit_build_core/settings/documentation.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import ast
+import inspect
+import sys
+import textwrap
+
+__all__ = ["pull_docs"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+def _get_value(value: ast.expr) -> str:
+    if sys.version_info < (3, 8):
+        assert isinstance(value, ast.Str)
+        return value.s
+
+    assert isinstance(value, ast.Constant)
+    return value.value
+
+
+def pull_docs(dc: type[object]) -> dict[str, str]:
+    """
+    Pulls documentation from a dataclass.
+    """
+    t = ast.parse(inspect.getsource(dc))
+    (obody,) = t.body
+    assert isinstance(obody, ast.ClassDef)
+    body = obody.body
+    return {
+        assign.target.id: textwrap.dedent(_get_value(expr.value)).strip().replace("\n", " ")  # type: ignore[union-attr]
+        for assign, expr in zip(body[:-1], body[1:])
+        if isinstance(assign, ast.AnnAssign) and isinstance(expr, ast.Expr)
+    }
diff --git a/src/scikit_build_core/settings/json_schema.py b/src/scikit_build_core/settings/json_schema.py
new file mode 100644
index 0000000..14e82b8
--- /dev/null
+++ b/src/scikit_build_core/settings/json_schema.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import dataclasses
+import typing
+from typing import Any
+
+from .._compat.typing import get_args, get_origin
+from .documentation import pull_docs
+
+__all__ = ["to_json_schema"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]:
+    assert dataclasses.is_dataclass(dclass)
+    props = {}
+    unknown = []
+    for field in dataclasses.fields(dclass):
+        if dataclasses.is_dataclass(field.type):
+            props[field.name] = to_json_schema(
+                field.type, normalize_keys=normalize_keys
+            )
+            continue
+        current_type = field.type
+        origin = get_origin(current_type)
+        args = get_args(current_type)
+        if (
+            origin is typing.Union
+            and len(args) == 2
+            and any(a is type(None) for a in args)
+        ):
+            current_type = next(iter(a for a in args if a is not type(None)))
+            origin = get_origin(current_type)
+            args = get_args(current_type)
+
+        if origin is list and args[0] is str:
+            props[field.name] = {"type": "array", "items": {"type": "string"}}
+        elif origin is dict and args[0] is str and args[1] is str:
+            props[field.name] = {
+                "type": "object",
+                "patternProperties": {".+": {"type": "string"}},
+            }
+        elif origin is dict and args[0] is str and get_origin(args[1]) is dict:
+            props[field.name] = {
+                "type": "object",
+                "patternProperties": {".+": {"type": "object"}},
+            }
+        elif current_type == str:
+            props[field.name] = {"type": "string"}
+        elif current_type == bool:
+            props[field.name] = {"type": "boolean"}
+        else:
+            unknown.append(
+                (
+                    field.name,
+                    field.type,
+                    get_origin(field.type),
+                    get_args(field.type),
+                )
+            )
+            continue
+
+        if field.default is not dataclasses.MISSING and field.default is not None:
+            props[field.name]["default"] = field.default
+
+    assert not unknown, f"{unknown} left over!"
+
+    docs = pull_docs(dclass)
+    for k, v in docs.items():
+        props[k]["description"] = v
+
+    if normalize_keys:
+        props = {k.replace("_", "-"): v for k, v in props.items()}
+
+    return {"type": "object", "additionalProperties": False, "properties": props}
diff --git a/src/scikit_build_core/settings/metadata.py b/src/scikit_build_core/settings/metadata.py
index f0462ca..bb6ba25 100644
--- a/src/scikit_build_core/settings/metadata.py
+++ b/src/scikit_build_core/settings/metadata.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import Any
 
+from packaging.version import Version
 from pyproject_metadata import StandardMetadata
 
 from ..settings.skbuild_model import ScikitBuildSettings
@@ -44,4 +45,11 @@ def get_standard_metadata(
         for field in fields:
             pyproject_dict["project"]["dynamic"].remove(field)
 
-    return StandardMetadata.from_pyproject(pyproject_dict)
+    metadata = StandardMetadata.from_pyproject(pyproject_dict)
+    # pyproject-metadata normalizes the name - see https://github.com/FFY00/python-pyproject-metadata/pull/65
+    # For scikit-build-core 0.5+, we keep the un-normalized name, and normalize it when using it for filenames
+    if settings.minimum_version is None or Version(settings.minimum_version) >= Version(
+        "0.5"
+    ):
+        metadata.name = pyproject_dict["project"]["name"]
+    return metadata
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 0a05850..6afbfae 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -20,123 +20,171 @@ def __dir__() -> List[str]:
 
 @dataclasses.dataclass
 class NinjaSettings:
-    #: The minimum version of Ninja to use. If Ninja is older than this, it will
-    #: be upgraded via PyPI if possible. An empty string will disable this check.
     minimum_version: str = "1.5"
+    """
+    The minimum version of Ninja to use. If Ninja is older than this, it will
+    be upgraded via PyPI if possible. An empty string will disable this check.
+    """
 
-    #: If make is present, do not add ninja if missing.
     make_fallback: bool = True
+    """
+    If make is present, do not add ninja if missing.
+    """
 
 
 @dataclasses.dataclass
 class CMakeSettings:
-    #: The minimum version of CMake to use. If CMake is older than this, it will
-    #: be upgraded via PyPI if possible. An empty string will disable this check.
     minimum_version: str = "3.15"
+    """
+    The minimum version of CMake to use. If CMake is older than this, it will
+    be upgraded via PyPI if possible. An empty string will disable this check.
+    """
 
-    #: A list of args to pass to CMake when configuring the project.
     args: List[str] = dataclasses.field(default_factory=list)
+    """
+    A list of args to pass to CMake when configuring the project.
+    """
 
-    #: A table of defines to pass to CMake when configuring the project. Additive.
     define: Dict[str, str] = dataclasses.field(default_factory=dict)
+    """
+    A table of defines to pass to CMake when configuring the project. Additive.
+    """
 
-    #: Verbose printout when building
     verbose: bool = False
+    """
+    Verbose printout when building
+    """
 
-    #: The build type to use when building the project.
-    #: Valid options are: "Debug", "Release", "RelWithDebInfo", "MinSizeRel",
-    #: "", etc.
     build_type: str = "Release"
+    """
+    The build type to use when building the project.
+    Valid options are: "Debug", "Release", "RelWithDebInfo", "MinSizeRel",
+    "", etc.
+    """
 
-    #: The source directory to use when building the project. Currently only
-    #: affects the native builder (not the setuptools plugin).
     source_dir: str = ""
+    """
+    The source directory to use when building the project. Currently only
+    affects the native builder (not the setuptools plugin).
+    """
 
-    #: The build targets to use when building the project. Empty builds the
-    #: default target.
     targets: List[str] = dataclasses.field(default_factory=list)
+    """
+    The build targets to use when building the project. Empty builds the
+    default target.
+    """
 
 
 @dataclasses.dataclass
 class LoggingSettings:
-    #: The logging level to display.
     level: str = "WARNING"
+    """
+    The logging level to display.
+    """
 
 
 @dataclasses.dataclass
 class SDistSettings:
-    #: Files to include in the SDist even if they are skipped by default.
     include: List[str] = dataclasses.field(default_factory=list)
+    """
+    Files to include in the SDist even if they are skipped by default.
+    """
 
-    #: Files to exclude from the SDist even if they are included by default.
     exclude: List[str] = dataclasses.field(default_factory=list)
+    """
+    Files to exclude from the SDist even if they are included by default.
+    """
 
-    #: If set to True, try to build a reproducible distribution.
-    #: SOURCE_DATE_EPOCH will be used for timestamps, or a fixed value if not set.
     reproducible: bool = True
+    """
+    If set to True, try to build a reproducible distribution.
+    ``SOURCE_DATE_EPOCH`` will be used for timestamps, or a fixed value if not set.
+    """
 
 
 @dataclasses.dataclass
 class WheelSettings:
-    #: A list of packages to auto-copy into the wheel. If this is None, it will
-    #: default to the first of ``src/<package>`` or ``<package>`` if they exist.
-    #: The prefix(s) will be stripped from the package name inside the wheel.
     packages: Optional[List[str]] = None
+    """
+    A list of packages to auto-copy into the wheel. If this is None, it will
+    default to the first of ``src/<package>`` or ``<package>`` if they exist.
+    The prefix(s) will be stripped from the package name inside the wheel.
+    """
 
-    #: The Python tags. The default (empty string) will use the default
-    #: Python version. You can also set this to "cp37" to enable the CPython
-    #: 3.7+ Stable ABI / Limited API (only on CPython and if the version is
-    #: sufficient, otherwise this has no effect). Or you can set it to "py3"
-    #: or "py2.py3" to ignore Python ABI compatibility. For the stable ABI,
-    #: the CMake variable SKBUILD_SOABI will be set to abi3 on Unix-like systems
-    #: (empty on Windows). FindPython doesn't have a way to target python3.dll
-    #: instead of python3N.dll, so this is harder to use on Windows.
-    #: The ABI tag is inferred from this tag.
     py_api: str = ""
+    """
+    The Python tags. The default (empty string) will use the default
+    Python version. You can also set this to "cp37" to enable the CPython
+    3.7+ Stable ABI / Limited API (only on CPython and if the version is
+    sufficient, otherwise this has no effect). Or you can set it to "py3"
+    or "py2.py3" to ignore Python ABI compatibility. For the stable ABI,
+    the CMake variable SKBUILD_SOABI will be set to abi3 on Unix-like systems
+    (empty on Windows). FindPython doesn't have a way to target python3.dll
+    instead of python3N.dll, so this is harder to use on Windows.
+    The ABI tag is inferred from this tag.
+    """
 
-    #: Fill out extra tags that are not required. This adds "x86_64" and "arm64"
-    #: to the list of platforms when "universal2" is used, which helps older
-    #: Pip's (before 21.0.1) find the correct wheel.
     expand_macos_universal_tags: bool = False
+    """
+    Fill out extra tags that are not required. This adds "x86_64" and "arm64"
+    to the list of platforms when "universal2" is used, which helps older
+    Pip's (before 21.0.1) find the correct wheel.
+    """
 
-    #: The install directory for the wheel. This is relative to the platlib root.
-    #: EXPERIMENTAL: An absolute path will be one level higher than the platlib
-    #: root, giving access to "/platlib", "/data", "/headers", and "/scripts".
     install_dir: str = ""
+    """
+    The install directory for the wheel. This is relative to the platlib root.
+    EXPERIMENTAL: An absolute path will be one level higher than the platlib
+    root, giving access to "/platlib", "/data", "/headers", and "/scripts".
+    """
 
-    #: A list of license files to include in the wheel. Supports glob patterns.
     license_files: List[str] = dataclasses.field(
         default_factory=lambda: ["LICEN[CS]E*", "COPYING*", "NOTICE*", "AUTHORS*"]
     )
+    """
+    A list of license files to include in the wheel. Supports glob patterns.
+    """
 
 
 @dataclasses.dataclass
 class BackportSettings:
-    #: If CMake is less than this value, backport a copy of FindPython. Set
-    #: to 0 disable this, or the empty string.
     find_python: str = "3.26.1"
+    """
+    If CMake is less than this value, backport a copy of FindPython. Set
+    to 0 disable this, or the empty string.
+    """
 
 
 @dataclasses.dataclass
 class EditableSettings:
-    #: Select the editable mode to use. Currently only "redirect" is supported.
     mode: str = "redirect"
+    """
+    Select the editable mode to use. Currently only "redirect" is supported.
+    """
 
-    #: Turn on verbose output for the editable mode rebuilds.
     verbose: bool = True
+    """
+    Turn on verbose output for the editable mode rebuilds.
+    """
 
-    #: Rebuild the project when the package is imported.
-    #: The build-directory must be set.
     rebuild: bool = False
+    """
+    Rebuild the project when the package is imported.
+    The build-directory must be set.
+    """
 
 
 @dataclasses.dataclass
 class InstallSettings:
-    #: The components to install. If empty, the default is used.
     components: List[str] = dataclasses.field(default_factory=list)
+    """
+    The components to install. If empty, the default is used.
+    """
 
-    #: Whether to strip the binaries. True for scikit-build-core 0.5+.
     strip: Optional[bool] = None
+    """
+    Whether to strip the binaries. True for scikit-build-core 0.5+.
+    """
 
 
 @dataclasses.dataclass
@@ -151,15 +199,23 @@ class ScikitBuildSettings:
     editable: EditableSettings = dataclasses.field(default_factory=EditableSettings)
     install: InstallSettings = dataclasses.field(default_factory=InstallSettings)
 
-    #: Strictly check all config options. If False, warnings will be
-    #: printed for unknown options. If True, an error will be raised.
     strict_config: bool = True
+    """
+    Strictly check all config options. If False, warnings will be
+    printed for unknown options. If True, an error will be raised.
+    """
 
-    #: Enable early previews of features not finalized yet.
     experimental: bool = False
+    """
+    Enable early previews of features not finalized yet.
+    """
 
-    #: If set, this will provide a method for backward compatibility.
     minimum_version: Optional[str] = None
+    """
+    If set, this will provide a method for backward compatibility.
+    """
 
-    #: The build directory. Defaults to a temporary directory, but can be set.
     build_dir: str = ""
+    """
+    The build directory. Defaults to a temporary directory, but can be set.
+    """
diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py
new file mode 100644
index 0000000..fe8d686
--- /dev/null
+++ b/src/scikit_build_core/settings/skbuild_schema.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from ..resources import resources
+
+__all__ = ["get_skbuild_schema", "generate_skbuild_schema"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
+    "Generate the complete schema for scikit-build settings."
+    assert tool_name == "scikit-build", "Only scikit-build is supported."
+
+    from .json_schema import to_json_schema
+    from .skbuild_model import ScikitBuildSettings
+
+    return {
+        "$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),
+    }
+
+
+def get_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
+    "Get the stored complete schema for scikit-build settings."
+    assert tool_name == "scikit-build", "Only scikit-build is supported."
+
+    with resources.joinpath("scikit-build.schema.json").open(encoding="utf-8") as f:
+        return json.load(f)  # type: ignore[no-any-return]
+
+
+if __name__ == "__main__":
+    d = generate_skbuild_schema()
+    print(json.dumps(d, indent=2))  # noqa: T201


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c27887a..f965e2f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -231,10 +231,10 @@ def package_simple_pyproject_ext(
 ) -> PackageInfo:
     package = PackageInfo(
         "simple_pyproject_ext",
-        "cbf3102cf86f709dfd3f244b69e5ee3faff9e3c94c53ef85f146d9bad96660b3",
-        "538a8221e10c5029c78127e4169d2df622b9a88dfce12a8b63eeaaa98cda8a09",
-        "0dab37b48497f95c044781204b7d49c752739b32a0ef7a7a02ec987b5c4a210b",
-        "feeedcb0070a211d93b967fe083aa31e448842eb9b93d5e428807348e40e44e8",
+        "c75641407303f3f3a0fd76d24e3d12447b31286fa5d0f687c0f8f8eb804d839f",
+        "dc4921bdf694b1b88cb27c8e6128513e174689625447d18c1dd3353348824946",
+        "2a135ce826265c5c1c73ebcb603989d8e2f4bca4605ad04c6c82871b5f75840e",
+        "7558e1db89e62b0941671796e1a35983a0b50a0701736566ca3ae5dd9ba09846",
     )
     process_package(package, tmp_path, monkeypatch)
     return package
diff --git a/tests/packages/simple_pyproject_ext/pyproject.toml b/tests/packages/simple_pyproject_ext/pyproject.toml
index b23fdf9..01748c9 100644
--- a/tests/packages/simple_pyproject_ext/pyproject.toml
+++ b/tests/packages/simple_pyproject_ext/pyproject.toml
@@ -6,7 +6,7 @@ requires = [
 build-backend = "scikit_build_core.build"
 
 [project]
-name = "cmake_example"
+name = "CMake.Example"
 version = "0.0.1"
 requires-python = ">=3.7"
 
diff --git a/tests/packages/simple_pyproject_source_dir/pyproject.toml b/tests/packages/simple_pyproject_source_dir/pyproject.toml
index 95bc8bb..6ddac46 100644
--- a/tests/packages/simple_pyproject_source_dir/pyproject.toml
+++ b/tests/packages/simple_pyproject_source_dir/pyproject.toml
@@ -9,7 +9,7 @@ build-backend = "scikit_build_core.build"
 cmake.source-dir = "src"
 
 [project]
-name = "cmake_example"
+name = "CMake.Example"
 version = "0.0.1"
 requires-python = ">=3.7"
 
diff --git a/tests/test_pyproject_pep517.py b/tests/test_pyproject_pep517.py
index a351da6..d4474b8 100644
--- a/tests/test_pyproject_pep517.py
+++ b/tests/test_pyproject_pep517.py
@@ -23,7 +23,7 @@ guithing = a.b:c
 """
 METADATA = """\
 Metadata-Version: 2.1
-Name: cmake-example
+Name: CMake.Example
 Version: 0.0.1
 Requires-Python: >=3.7
 Provides-Extra: test
@@ -43,13 +43,13 @@ def test_pep517_sdist():
     out = build_sdist("dist")
 
     (sdist,) = dist.iterdir()
-    assert sdist.name == "cmake-example-0.0.1.tar.gz"
+    assert sdist.name == "cmake_example-0.0.1.tar.gz"
     assert sdist == dist / out
 
     with tarfile.open(sdist) as f:
         file_names = set(f.getnames())
         assert file_names == {
-            f"cmake-example-0.0.1/{x}"
+            f"cmake_example-0.0.1/{x}"
             for x in (
                 "CMakeLists.txt",
                 "pyproject.toml",
@@ -58,7 +58,7 @@ def test_pep517_sdist():
                 "LICENSE",
             )
         }
-        pkg_info = f.extractfile("cmake-example-0.0.1/PKG-INFO")
+        pkg_info = f.extractfile("cmake_example-0.0.1/PKG-INFO")
         assert pkg_info
         pkg_info_contents = pkg_info.read().decode()
         assert pkg_info_contents == METADATA
@@ -173,7 +173,7 @@ def test_pep517_wheel(virtualenv):
         print(entry_points == ENTRYPOINTS)
         assert 'Requires-Dist: pytest>=6.0; extra == "test"' in metadata
         assert "Metadata-Version: 2.1" in metadata
-        assert "Name: cmake-example" in metadata
+        assert "Name: CMake.Example" in metadata
         assert "Version: 0.0.1" in metadata
         assert "Requires-Python: >=3.7" in metadata
         assert "Provides-Extra: test" in metadata
@@ -221,7 +221,7 @@ def test_pep517_wheel_source_dir(virtualenv):
         print(entry_points == ENTRYPOINTS)
         assert 'Requires-Dist: pytest>=6.0; extra == "test"' in metadata
         assert "Metadata-Version: 2.1" in metadata
-        assert "Name: cmake-example" in metadata
+        assert "Name: CMake.Example" in metadata
         assert "Version: 0.0.1" in metadata
         assert "Requires-Python: >=3.7" in metadata
         assert "Provides-Extra: test" in metadata
@@ -268,7 +268,7 @@ def test_prepare_metdata_for_build_wheel():
     metadata = build.util.project_wheel_metadata(str(Path.cwd()), isolated=False)
     answer = {
         "Metadata-Version": "2.1",
-        "Name": "cmake-example",
+        "Name": "CMake.Example",
         "Version": "0.0.1",
         "Requires-Python": ">=3.7",
         "Provides-Extra": "test",
diff --git a/tests/test_pyproject_pep518.py b/tests/test_pyproject_pep518.py
index a46a98c..5b514a2 100644
--- a/tests/test_pyproject_pep518.py
+++ b/tests/test_pyproject_pep518.py
@@ -1,6 +1,5 @@
 import hashlib
 import shutil
-import subprocess
 import sys
 import tarfile
 import textwrap
@@ -12,11 +11,11 @@ import pytest
 
 @pytest.mark.network()
 @pytest.mark.integration()
-def test_pep518_sdist(package_simple_pyproject_ext):
+def test_pep518_sdist(isolated, package_simple_pyproject_ext):
     correct_metadata = textwrap.dedent(
         """\
         Metadata-Version: 2.1
-        Name: cmake-example
+        Name: CMake.Example
         Version: 0.0.1
         Requires-Python: >=3.7
         Provides-Extra: test
@@ -24,9 +23,10 @@ def test_pep518_sdist(package_simple_pyproject_ext):
         """
     )
 
-    subprocess.run([sys.executable, "-m", "build", "--sdist"], check=True)
+    isolated.install("build[virtualenv]")
+    isolated.module("build", "--sdist")
     (sdist,) = Path("dist").iterdir()
-    assert sdist.name == "cmake-example-0.0.1.tar.gz"
+    assert sdist.name == "cmake_example-0.0.1.tar.gz"
 
     if not sys.platform.startswith(("win", "cygwin")):
         hash = hashlib.sha256(sdist.read_bytes()).hexdigest()
@@ -35,7 +35,7 @@ def test_pep518_sdist(package_simple_pyproject_ext):
     with tarfile.open(sdist) as f:
         file_names = set(f.getnames())
         assert file_names == {
-            f"cmake-example-0.0.1/{x}"
+            f"cmake_example-0.0.1/{x}"
             for x in (
                 "CMakeLists.txt",
                 "pyproject.toml",
@@ -44,7 +44,7 @@ def test_pep518_sdist(package_simple_pyproject_ext):
                 "LICENSE",
             )
         }
-        pkg_info = f.extractfile("cmake-example-0.0.1/PKG-INFO")
+        pkg_info = f.extractfile("cmake_example-0.0.1/PKG-INFO")
         assert pkg_info
         pkg_info_contents = pkg_info.read().decode()
         assert correct_metadata == pkg_info_contents
diff --git a/tests/test_schema.py b/tests/test_schema.py
new file mode 100644
index 0000000..242ec9e
--- /dev/null
+++ b/tests/test_schema.py
@@ -0,0 +1,12 @@
+from scikit_build_core.settings.skbuild_schema import (
+    generate_skbuild_schema,
+    get_skbuild_schema,
+)
+
+
+def test_compare_schemas():
+    """
+    Should be the same. If not, run nox -s generate_schema
+    """
+
+    assert generate_skbuild_schema() == get_skbuild_schema()