Repository: scikit-build/scikit-build-core (python)

Issue:
Embed pyproject-metadata
Still waiting for a release, so I finally think it's time to embed pyproject-metadata. We are carrying around patches for it, and even once it releases, it will be some time before we can depend on the new version. And we guarantee stability of builds, which is hard if it depends on the pyproject-metadata version. PDM-backend already embeds it, so I think we should too.

This will allow us to move to METADATA 2.2 if we want to. (2.3 is still a bit too new, IMO).

Pull request:
feat: vendor pyproject-metadata
This will enable us to use new features immediately.

Close #688.

Need to ignore the vendored file in a few places like coverage.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9fee78c..4328e00 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,6 +2,8 @@ ci:
   autoupdate_commit_msg: "chore(deps): update pre-commit hooks"
   autofix_commit_msg: "style: pre-commit fixes"
 
+exclude: ^src/scikit_build_core/_vendor
+
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
     rev: v4.6.0
@@ -79,7 +81,6 @@ repos:
           - ninja
           - nox
           - packaging
-          - pyproject_metadata
           - pytest<8
           - rich
           - setuptools-scm
diff --git a/docs/conf.py b/docs/conf.py
index 402253a..18e81ed 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -96,7 +96,6 @@ intersphinx_mapping = {
     "python": ("https://docs.python.org/3", None),
     "packaging": ("https://packaging.pypa.io/en/stable", None),
     "setuptools": ("https://setuptools.pypa.io/en/latest", None),
-    "pyproject_metadata": ("https://pep621.readthedocs.io/en/latest", None),
     "hatchling": ("https://hatch.pypa.io/latest", None),
 }
 
@@ -104,6 +103,7 @@ nitpick_ignore = [
     ("py:class", "setuptools.dist.Distribution"),
     ("py:class", "T"),
     ("py:class", "scikit_build_core.settings.sources.T"),
+    ("py:class", "scikit_build_core._vendor.pyproject_metadata.StandardMetadata"),
 ]
 
 linkcheck_anchors_ignore = [
diff --git a/noxfile.py b/noxfile.py
index 9911032..c411677 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -282,3 +282,29 @@ def downstream(session: nox.Session) -> None:
 
     if args.code:
         session.run("python", "-c", args.code)
+
+
+@nox.session(venv_backend="none")
+def vendor_pyproject_metadata(session: nox.Session) -> None:
+    """
+    Vendor pyproject.toml metadata.
+    """
+
+    parser = argparse.ArgumentParser(
+        prog=f"{Path(sys.argv[0]).name} -s vendor_pyproject_metadata"
+    )
+    parser.add_argument("version", help="A tag or ref to vendor")
+    args = parser.parse_args(session.posargs)
+
+    session.run(
+        "curl",
+        "-o",
+        "src/scikit_build_core/_vendor/pyproject_metadata/__init__.py",
+        f"https://raw.githubusercontent.com/pypa/pyproject-metadata/{args.version}/pyproject_metadata/__init__.py",
+    )
+    session.run(
+        "curl",
+        "-o",
+        "src/scikit_build_core/_vendor/pyproject_metadata/LICENSE",
+        f"https://raw.githubusercontent.com/pypa/pyproject-metadata/{args.version}/LICENSE",
+    )
diff --git a/pyproject.toml b/pyproject.toml
index a6c4376..04a55bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -201,6 +201,7 @@ messages_control.disable = [
 
 [tool.coverage]
 run.source = ["scikit_build_core"]
+run.omit = ["src/scikit_build_core/_vendor"]
 report.exclude_lines = [
     'pragma: no cover',
     '\.\.\.',
@@ -214,7 +215,7 @@ ignore = ["W002"]  # Triggers on __init__.py's
 
 [tool.ruff]
 src = ["src"]
-exclude = []  # Required due to "build" module
+exclude = ["src/scikit_build_core/_vendor"]  # Required due to "build" module
 
 [tool.ruff.lint]
 extend-select = [
@@ -289,6 +290,7 @@ typing-modules = ["scikit_build_core._compat.typing"]
 "importlib_metadata".msg = "Use scikit_build_core._compat.importlib.metadata instead."
 "importlib.resources".msg = "Use scikit_build_core._compat.importlib.resources instead."
 "importlib_resources".msg = "Use scikit_build_core._compat.importlib.resources instead."
+"pyproject_metadata".msg = "Use scikit_build_core._vendor.pyproject_metadata instead."
 
 
 [tool.ruff.lint.per-file-ignores]
diff --git a/src/scikit_build_core/_vendor/pyproject_metadata/LICENSE b/src/scikit_build_core/_vendor/pyproject_metadata/LICENSE
new file mode 100644
index 0000000..c3713cd
--- /dev/null
+++ b/src/scikit_build_core/_vendor/pyproject_metadata/LICENSE
@@ -0,0 +1,20 @@
+Copyright © 2019 Filipe Laíns <filipe.lains@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice (including the next
+paragraph) shall be included in all copies or substantial portions of the
+Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
diff --git a/src/scikit_build_core/_vendor/pyproject_metadata/__init__.py b/src/scikit_build_core/_vendor/pyproject_metadata/__init__.py
new file mode 100644
index 0000000..7ebc91d
--- /dev/null
+++ b/src/scikit_build_core/_vendor/pyproject_metadata/__init__.py
@@ -0,0 +1,542 @@
+# SPDX-License-Identifier: MIT
+
+from __future__ import annotations
+
+import collections
+import copy
+import dataclasses
+import email.utils
+import os
+import os.path
+import pathlib
+import sys
+import typing
+
+
+if typing.TYPE_CHECKING:
+    from collections.abc import Mapping
+    from typing import Any
+
+    from packaging.requirements import Requirement
+
+    if sys.version_info < (3, 11):
+        from typing_extensions import Self
+    else:
+        from typing import Self
+
+import packaging.markers
+import packaging.requirements
+import packaging.specifiers
+import packaging.utils
+import packaging.version
+
+
+__version__ = '0.8.0'
+
+KNOWN_METADATA_VERSIONS = {'2.1', '2.2', '2.3'}
+
+
+class ConfigurationError(Exception):
+    '''Error in the backend metadata.'''
+    def __init__(self, msg: str, *, key: str | None = None):
+        super().__init__(msg)
+        self._key = key
+
+    @property
+    def key(self) -> str | None:  # pragma: no cover
+        return self._key
+
+
+class RFC822Message:
+    '''Python-flavored RFC 822 message implementation.'''
+
+    def __init__(self) -> None:
+        self.headers: collections.OrderedDict[str, list[str]] = collections.OrderedDict()
+        self.body: str | None = None
+
+    def __setitem__(self, name: str, value: str | None) -> None:
+        if not value:
+            return
+        if name not in self.headers:
+            self.headers[name] = []
+        self.headers[name].append(value)
+
+    def __str__(self) -> str:
+        text = ''
+        for name, entries in self.headers.items():
+            for entry in entries:
+                lines = entry.strip('\n').split('\n')
+                text += f'{name}: {lines[0]}\n'
+                for line in lines[1:]:
+                    text += ' ' * 8 + line + '\n'
+        if self.body:
+            text += '\n' + self.body
+        return text
+
+    def __bytes__(self) -> bytes:
+        return str(self).encode()
+
+
+class DataFetcher:
+    def __init__(self, data: Mapping[str, Any]) -> None:
+        self._data = data
+
+    def __contains__(self, key: Any) -> bool:
+        if not isinstance(key, str):
+            return False
+        val = self._data
+        try:
+            for part in key.split('.'):
+                val = val[part]
+        except KeyError:
+            return False
+        return True
+
+    def get(self, key: str) -> Any:
+        val = self._data
+        for part in key.split('.'):
+            val = val[part]
+        return val
+
+    def get_str(self, key: str) -> str | None:
+        try:
+            val = self.get(key)
+            if not isinstance(val, str):
+                msg = f'Field "{key}" has an invalid type, expecting a string (got "{val}")'
+                raise ConfigurationError(msg, key=key)
+            return val
+        except KeyError:
+            return None
+
+    def get_list(self, key: str) -> list[str]:
+        try:
+            val = self.get(key)
+            if not isinstance(val, list):
+                msg = f'Field "{key}" has an invalid type, expecting a list of strings (got "{val}")'
+                raise ConfigurationError(msg, key=val)
+            for item in val:
+                if not isinstance(item, str):
+                    msg = f'Field "{key}" contains item with invalid type, expecting a string (got "{item}")'
+                    raise ConfigurationError(msg, key=key)
+            return val
+        except KeyError:
+            return []
+
+    def get_dict(self, key: str) -> dict[str, str]:
+        try:
+            val = self.get(key)
+            if not isinstance(val, dict):
+                msg = f'Field "{key}" has an invalid type, expecting a dictionary of strings (got "{val}")'
+                raise ConfigurationError(msg, key=key)
+            for subkey, item in val.items():
+                if not isinstance(item, str):
+                    msg = f'Field "{key}.{subkey}" has an invalid type, expecting a string (got "{item}")'
+                    raise ConfigurationError(msg, key=f'{key}.{subkey}')
+            return val
+        except KeyError:
+            return {}
+
+    def get_people(self, key: str) -> list[tuple[str, str]]:
+        try:
+            val = self.get(key)
+            if not (
+                isinstance(val, list)
+                and all(isinstance(x, dict) for x in val)
+                and all(
+                    isinstance(item, str)
+                    for items in [_dict.values() for _dict in val]
+                    for item in items
+                )
+            ):
+                msg = (
+                    f'Field "{key}" has an invalid type, expecting a list of '
+                    f'dictionaries containing the "name" and/or "email" keys (got "{val}")'
+                )
+                raise ConfigurationError(msg, key=key)
+            return [
+                (entry.get('name', 'Unknown'), entry.get('email'))
+                for entry in val
+            ]
+        except KeyError:
+            return []
+
+
+class License(typing.NamedTuple):
+    text: str
+    file: pathlib.Path | None
+
+
+class Readme(typing.NamedTuple):
+    text: str
+    file: pathlib.Path | None
+    content_type: str
+
+
+@dataclasses.dataclass
+class StandardMetadata:
+    name: str
+    version: packaging.version.Version | None = None
+    description: str | None = None
+    license: License | None = None
+    readme: Readme | None = None
+    requires_python: packaging.specifiers.SpecifierSet | None = None
+    dependencies: list[Requirement] = dataclasses.field(default_factory=list)
+    optional_dependencies: dict[str, list[Requirement]] = dataclasses.field(default_factory=dict)
+    entrypoints: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict)
+    authors: list[tuple[str, str]] = dataclasses.field(default_factory=list)
+    maintainers: list[tuple[str, str]] = dataclasses.field(default_factory=list)
+    urls: dict[str, str] = dataclasses.field(default_factory=dict)
+    classifiers: list[str] = dataclasses.field(default_factory=list)
+    keywords: list[str] = dataclasses.field(default_factory=list)
+    scripts: dict[str, str] = dataclasses.field(default_factory=dict)
+    gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict)
+    dynamic: list[str] = dataclasses.field(default_factory=list)
+
+    _metadata_version: str | None = None
+
+    @property
+    def metadata_version(self) -> str:
+        if self._metadata_version is None:
+            return '2.2' if self.dynamic else '2.1'
+        return self._metadata_version
+
+    @property
+    def canonical_name(self) -> str:
+        return packaging.utils.canonicalize_name(self.name)
+
+    @classmethod
+    def from_pyproject(
+        cls,
+        data: Mapping[str, Any],
+        project_dir: str | os.PathLike[str] = os.path.curdir,
+        metadata_version: str | None = None,
+    ) -> Self:
+        fetcher = DataFetcher(data)
+        project_dir = pathlib.Path(project_dir)
+
+        if 'project' not in fetcher:
+            msg = 'Section "project" missing in pyproject.toml'
+            raise ConfigurationError(msg)
+
+        dynamic = fetcher.get_list('project.dynamic')
+        if 'name' in dynamic:
+            msg = 'Unsupported field "name" in "project.dynamic"'
+            raise ConfigurationError(msg)
+
+        for field in dynamic:
+            if field in data['project']:
+                msg = f'Field "project.{field}" declared as dynamic in "project.dynamic" but is defined'
+                raise ConfigurationError(msg)
+
+        name = fetcher.get_str('project.name')
+        if not name:
+            msg = 'Field "project.name" missing'
+            raise ConfigurationError(msg)
+
+        version_string = fetcher.get_str('project.version')
+        requires_python_string = fetcher.get_str('project.requires-python')
+        version = packaging.version.Version(version_string) if version_string else None
+
+        if version is None and 'version' not in dynamic:
+            msg = 'Field "project.version" missing and "version" not specified in "project.dynamic"'
+            raise ConfigurationError(msg)
+
+        # Description fills Summary, which cannot be multiline
+        # However, throwing an error isn't backward compatible,
+        # so leave it up to the users for now.
+        description = fetcher.get_str('project.description')
+
+        if metadata_version and metadata_version not in KNOWN_METADATA_VERSIONS:
+            msg = f'The metadata_version must be one of {KNOWN_METADATA_VERSIONS} or None (default)'
+            raise ConfigurationError(msg)
+
+        return cls(
+            name,
+            version,
+            description,
+            cls._get_license(fetcher, project_dir),
+            cls._get_readme(fetcher, project_dir),
+            packaging.specifiers.SpecifierSet(requires_python_string) if requires_python_string else None,
+            cls._get_dependencies(fetcher),
+            cls._get_optional_dependencies(fetcher),
+            cls._get_entrypoints(fetcher),
+            fetcher.get_people('project.authors'),
+            fetcher.get_people('project.maintainers'),
+            fetcher.get_dict('project.urls'),
+            fetcher.get_list('project.classifiers'),
+            fetcher.get_list('project.keywords'),
+            fetcher.get_dict('project.scripts'),
+            fetcher.get_dict('project.gui-scripts'),
+            dynamic,
+            metadata_version,
+        )
+
+    def _update_dynamic(self, value: Any) -> None:
+        if value and 'version' in self.dynamic:
+            self.dynamic.remove('version')
+
+    def __setattr__(self, name: str, value: Any) -> None:
+        # update dynamic when version is set
+        if name == 'version' and hasattr(self, 'dynamic'):
+            self._update_dynamic(value)
+        super().__setattr__(name, value)
+
+    def as_rfc822(self) -> RFC822Message:
+        message = RFC822Message()
+        self.write_to_rfc822(message)
+        return message
+
+    def write_to_rfc822(self, message: RFC822Message) -> None:  # noqa: C901
+        message['Metadata-Version'] = self.metadata_version
+        message['Name'] = self.name
+        if not self.version:
+            msg = 'Missing version field'
+            raise ConfigurationError(msg)
+        message['Version'] = str(self.version)
+        # skip 'Platform'
+        # skip 'Supported-Platform'
+        if self.description:
+            message['Summary'] = self.description
+        message['Keywords'] = ','.join(self.keywords)
+        if 'homepage' in self.urls:
+            message['Home-page'] = self.urls['homepage']
+        # skip 'Download-URL'
+        message['Author'] = self._name_list(self.authors)
+        message['Author-Email'] = self._email_list(self.authors)
+        message['Maintainer'] = self._name_list(self.maintainers)
+        message['Maintainer-Email'] = self._email_list(self.maintainers)
+        if self.license:
+            message['License'] = self.license.text
+        for classifier in self.classifiers:
+            message['Classifier'] = classifier
+        # skip 'Provides-Dist'
+        # skip 'Obsoletes-Dist'
+        # skip 'Requires-External'
+        for name, url in self.urls.items():
+            message['Project-URL'] = f'{name.capitalize()}, {url}'
+        if self.requires_python:
+            message['Requires-Python'] = str(self.requires_python)
+        for dep in self.dependencies:
+            message['Requires-Dist'] = str(dep)
+        for extra, requirements in self.optional_dependencies.items():
+            norm_extra = extra.replace('.', '-').replace('_', '-').lower()
+            message['Provides-Extra'] = norm_extra
+            for requirement in requirements:
+                message['Requires-Dist'] = str(self._build_extra_req(norm_extra, requirement))
+        if self.readme:
+            if self.readme.content_type:
+                message['Description-Content-Type'] = self.readme.content_type
+            message.body = self.readme.text
+        # Core Metadata 2.2
+        if self.metadata_version != '2.1':
+            for field in self.dynamic:
+                if field in ('name', 'version'):
+                    msg = f'Field cannot be dynamic: {field}'
+                    raise ConfigurationError(msg)
+                message['Dynamic'] = field
+
+    def _name_list(self, people: list[tuple[str, str]]) -> str:
+        return ', '.join(
+            name
+            for name, email_ in people
+            if not email_
+        )
+
+    def _email_list(self, people: list[tuple[str, str]]) -> str:
+        return ', '.join(
+            email.utils.formataddr((name, _email))
+            for name, _email in people
+            if _email
+        )
+
+    def _build_extra_req(
+        self,
+        extra: str,
+        requirement: Requirement,
+    ) -> Requirement:
+        # append or add our extra marker
+        requirement = copy.copy(requirement)
+        if requirement.marker:
+            if 'or' in requirement.marker._markers:
+                requirement.marker = packaging.markers.Marker(
+                    f'({requirement.marker}) and extra == "{extra}"'
+                )
+            else:
+                requirement.marker = packaging.markers.Marker(
+                    f'{requirement.marker} and extra == "{extra}"'
+                )
+        else:
+            requirement.marker = packaging.markers.Marker(f'extra == "{extra}"')
+        return requirement
+
+    @staticmethod
+    def _get_license(fetcher: DataFetcher, project_dir: pathlib.Path) -> License | None:
+        if 'project.license' not in fetcher:
+            return None
+
+        _license = fetcher.get_dict('project.license')
+        for field in _license:
+            if field not in ('file', 'text'):
+                msg = f'Unexpected field "project.license.{field}"'
+                raise ConfigurationError(msg, key=f'project.license.{field}')
+
+        file: pathlib.Path | None = None
+        filename = fetcher.get_str('project.license.file')
+        text = fetcher.get_str('project.license.text')
+
+        if (filename and text) or (not filename and not text):
+            msg = f'Invalid "project.license" value, expecting either "file" or "text" (got "{_license}")'
+            raise ConfigurationError(msg, key='project.license')
+
+        if filename:
+            file = project_dir.joinpath(filename)
+            if not file.is_file():
+                msg = f'License file not found ("{filename}")'
+                raise ConfigurationError(msg, key='project.license.file')
+            text = file.read_text(encoding='utf-8')
+
+        assert text is not None
+        return License(text, file)
+
+    @staticmethod
+    def _get_readme(fetcher: DataFetcher, project_dir: pathlib.Path) -> Readme | None:  # noqa: C901
+        if 'project.readme' not in fetcher:
+            return None
+
+        filename: str | None
+        file: pathlib.Path | None = None
+        text: str | None
+        content_type: str | None
+
+        readme = fetcher.get('project.readme')
+        if isinstance(readme, str):
+            # readme is a file
+            text = None
+            filename = readme
+            if filename.endswith('.md'):
+                content_type = 'text/markdown'
+            elif filename.endswith('.rst'):
+                content_type = 'text/x-rst'
+            else:
+                msg = f'Could not infer content type for readme file "{filename}"'
+                raise ConfigurationError(msg, key='project.readme')
+        elif isinstance(readme, dict):
+            # readme is a dict containing either 'file' or 'text', and content-type
+            for field in readme:
+                if field not in ('content-type', 'file', 'text'):
+                    msg = f'Unexpected field "project.readme.{field}"'
+                    raise ConfigurationError(msg, key=f'project.readme.{field}')
+            content_type = fetcher.get_str('project.readme.content-type')
+            filename = fetcher.get_str('project.readme.file')
+            text = fetcher.get_str('project.readme.text')
+            if (filename and text) or (not filename and not text):
+                msg = f'Invalid "project.readme" value, expecting either "file" or "text" (got "{readme}")'
+                raise ConfigurationError(msg, key='project.readme')
+            if not content_type:
+                msg = 'Field "project.readme.content-type" missing'
+                raise ConfigurationError(msg, key='project.readme.content-type')
+        else:
+            msg = (
+                f'Field "project.readme" has an invalid type, expecting either, '
+                f'a string or dictionary of strings (got "{readme}")'
+            )
+            raise ConfigurationError(msg, key='project.readme')
+
+        if filename:
+            file = project_dir.joinpath(filename)
+            if not file.is_file():
+                msg = f'Readme file not found ("{filename}")'
+                raise ConfigurationError(msg, key='project.readme.file')
+            text = file.read_text(encoding='utf-8')
+
+        assert text is not None
+        return Readme(text, file, content_type)
+
+    @staticmethod
+    def _get_dependencies(fetcher: DataFetcher) -> list[Requirement]:
+        try:
+            requirement_strings = fetcher.get_list('project.dependencies')
+        except KeyError:
+            return []
+
+        requirements: list[Requirement] = []
+        for req in requirement_strings:
+            try:
+                requirements.append(packaging.requirements.Requirement(req))
+            except packaging.requirements.InvalidRequirement as e:
+                msg = (
+                    'Field "project.dependencies" contains an invalid PEP 508 '
+                    f'requirement string "{req}" ("{e}")'
+                )
+                raise ConfigurationError(msg) from None
+        return requirements
+
+    @staticmethod
+    def _get_optional_dependencies(fetcher: DataFetcher) -> dict[str, list[Requirement]]:
+        try:
+            val = fetcher.get('project.optional-dependencies')
+        except KeyError:
+            return {}
+
+        requirements_dict: dict[str, list[Requirement]] = {}
+        if not isinstance(val, dict):
+            msg = (
+                'Field "project.optional-dependencies" has an invalid type, expecting a '
+                f'dictionary of PEP 508 requirement strings (got "{val}")'
+            )
+            raise ConfigurationError(msg)
+        for extra, requirements in val.copy().items():
+            assert isinstance(extra, str)
+            if not isinstance(requirements, list):
+                msg = (
+                    f'Field "project.optional-dependencies.{extra}" has an invalid type, expecting a '
+                    f'dictionary PEP 508 requirement strings (got "{requirements}")'
+                )
+                raise ConfigurationError(msg)
+            requirements_dict[extra] = []
+            for req in requirements:
+                if not isinstance(req, str):
+                    msg = (
+                        f'Field "project.optional-dependencies.{extra}" has an invalid type, '
+                        f'expecting a PEP 508 requirement string (got "{req}")'
+                    )
+                    raise ConfigurationError(msg)
+                try:
+                    requirements_dict[extra].append(packaging.requirements.Requirement(req))
+                except packaging.requirements.InvalidRequirement as e:
+                    msg = (
+                        f'Field "project.optional-dependencies.{extra}" contains '
+                        f'an invalid PEP 508 requirement string "{req}" ("{e}")'
+                    )
+                    raise ConfigurationError(msg) from None
+        return dict(requirements_dict)
+
+    @staticmethod
+    def _get_entrypoints(fetcher: DataFetcher) -> dict[str, dict[str, str]]:
+        try:
+            val = fetcher.get('project.entry-points')
+        except KeyError:
+            return {}
+        if not isinstance(val, dict):
+            msg = (
+                'Field "project.entry-points" has an invalid type, expecting a '
+                f'dictionary of entrypoint sections (got "{val}")'
+            )
+            raise ConfigurationError(msg)
+        for section, entrypoints in val.items():
+            assert isinstance(section, str)
+            if not isinstance(entrypoints, dict):
+                msg = (
+                    f'Field "project.entry-points.{section}" has an invalid type, expecting a '
+                    f'dictionary of entrypoints (got "{entrypoints}")'
+                )
+                raise ConfigurationError(msg)
+            for name, entrypoint in entrypoints.items():
+                assert isinstance(name, str)
+                if not isinstance(entrypoint, str):
+                    msg = (
+                        f'Field "project.entry-points.{section}.{name}" has an invalid type, '
+                        f'expecting a string (got "{entrypoint}")'
+                    )
+                    raise ConfigurationError(msg)
+        return val
diff --git a/src/scikit_build_core/build/__init__.py b/src/scikit_build_core/build/__init__.py
index 941b8d6..8da7f0c 100644
--- a/src/scikit_build_core/build/__init__.py
+++ b/src/scikit_build_core/build/__init__.py
@@ -110,7 +110,6 @@ def get_requires_for_build_sdist(
 
     return [
         "pathspec",
-        "pyproject_metadata",
         *cmake_requires,
         *requires.dynamic_metadata(),
     ]
@@ -130,7 +129,6 @@ def get_requires_for_build_wheel(
 
     return [
         "pathspec",
-        "pyproject_metadata",
         *cmake_requires,
         *requires.dynamic_metadata(),
     ]
@@ -150,7 +148,6 @@ def get_requires_for_build_editable(
 
     return [
         "pathspec",
-        "pyproject_metadata",
         *cmake_requires,
         *requires.dynamic_metadata(),
     ]
diff --git a/src/scikit_build_core/build/_wheelfile.py b/src/scikit_build_core/build/_wheelfile.py
index 777b8c4..7672554 100644
--- a/src/scikit_build_core/build/_wheelfile.py
+++ b/src/scikit_build_core/build/_wheelfile.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import base64
-import copy
 import csv
 import dataclasses
 import hashlib
@@ -16,7 +15,6 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 from zipfile import ZipInfo
 
-import packaging.utils
 import pathspec
 
 from .. import __version__
@@ -25,9 +23,9 @@ if TYPE_CHECKING:
     from collections.abc import Mapping, Sequence, Set
 
     from packaging.tags import Tag
-    from pyproject_metadata import StandardMetadata
 
     from .._compat.typing import Self
+    from .._vendor.pyproject_metadata import StandardMetadata
 
 EMAIL_POLICY = EmailPolicy(max_line_length=0, mangle_from_=False, utf8=True)
 
@@ -81,7 +79,7 @@ class WheelWriter:
 
     @property
     def name_ver(self) -> str:
-        name = packaging.utils.canonicalize_name(self.metadata.name).replace("-", "_")
+        name = self.metadata.canonical_name.replace("-", "_")
         # replace - with _ as a local version separator
         version = str(self.metadata.version).replace("-", "_")
         return f"{name}-{version}"
@@ -125,9 +123,7 @@ class WheelWriter:
 
         self.wheel_metadata.tags = self.tags
 
-        # Using deepcopy here because of a bug in pyproject-metadata
-        # https://github.com/FFY00/python-pyproject-metadata/pull/49
-        rfc822 = copy.deepcopy(self.metadata).as_rfc822()
+        rfc822 = self.metadata.as_rfc822()
 
         metadata_files = self.metadata_dir.rglob("*") if self.metadata_dir else []
         extra_metadata = {
diff --git a/src/scikit_build_core/build/generate.py b/src/scikit_build_core/build/generate.py
index af9811a..03547f3 100644
--- a/src/scikit_build_core/build/generate.py
+++ b/src/scikit_build_core/build/generate.py
@@ -7,8 +7,7 @@ import string
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from pyproject_metadata import StandardMetadata
-
+    from .._vendor.pyproject_metadata import StandardMetadata
     from ..settings.skbuild_model import GenerateSettings
 
 
diff --git a/src/scikit_build_core/build/metadata.py b/src/scikit_build_core/build/metadata.py
index 520297e..68c1082 100644
--- a/src/scikit_build_core/build/metadata.py
+++ b/src/scikit_build_core/build/metadata.py
@@ -3,8 +3,8 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 from packaging.version import Version
-from pyproject_metadata import StandardMetadata
 
+from .._vendor.pyproject_metadata import StandardMetadata
 from ..settings._load_provider import load_dynamic_metadata
 
 if TYPE_CHECKING:
@@ -38,10 +38,11 @@ def get_standard_metadata(
 
     metadata = StandardMetadata.from_pyproject(new_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 settings.minimum_version >= Version("0.5"):
-        metadata.name = new_pyproject_dict["project"]["name"]
+    # For scikit-build-core < 0.5, we keep the normalized name for back-compat
+    if settings.minimum_version is not None and settings.minimum_version < Version(
+        "0.5"
+    ):
+        metadata.name = metadata.canonical_name
 
     # The description field is required to be one line. Instead of merging it
     # or cutting off subsequent lines (setuptools), we throw a nice error.


Test patch:
diff --git a/tests/test_dynamic_metadata.py b/tests/test_dynamic_metadata.py
index 0d4dd57..39f1984 100644
--- a/tests/test_dynamic_metadata.py
+++ b/tests/test_dynamic_metadata.py
@@ -9,11 +9,11 @@ import zipfile
 from pathlib import Path
 from typing import Any
 
-import pyproject_metadata
 import pytest
 from packaging.version import Version
 
 from scikit_build_core._compat import tomllib
+from scikit_build_core._vendor import pyproject_metadata
 from scikit_build_core.build import build_wheel
 from scikit_build_core.build.metadata import get_standard_metadata
 from scikit_build_core.builder.get_requires import GetRequires
diff --git a/tests/test_get_requires.py b/tests/test_get_requires.py
index 6a060c0..5c916a5 100644
--- a/tests/test_get_requires.py
+++ b/tests/test_get_requires.py
@@ -116,11 +116,11 @@ def test_get_requires_for_build_sdist(fp):
         [Path("cmake/path"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.14.0"}}',
     )
-    assert set(get_requires_for_build_sdist({})) == {"pathspec", "pyproject_metadata"}
+    assert set(get_requires_for_build_sdist({})) == {"pathspec"}
 
 
 def test_get_requires_for_build_sdist_cmake(fp):
-    expected = {"pathspec", "pyproject_metadata", "cmake>=3.15", *ninja}
+    expected = {"pathspec", "cmake>=3.15", *ninja}
     fp.register(
         [Path("cmake/path"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.14.0"}}',
@@ -129,7 +129,7 @@ def test_get_requires_for_build_sdist_cmake(fp):
 
 
 def test_get_requires_for_build_wheel(fp):
-    expected = {"pathspec", "pyproject_metadata", "cmake>=3.15", *ninja}
+    expected = {"pathspec", "cmake>=3.15", *ninja}
     fp.register(
         [Path("cmake/path"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.14.0"}}',
@@ -138,7 +138,7 @@ def test_get_requires_for_build_wheel(fp):
 
 
 def test_get_requires_for_build_wheel_pure(fp):
-    expected = {"pathspec", "pyproject_metadata"}
+    expected = {"pathspec"}
     fp.register(
         [Path("cmake/path"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.14.0"}}',
@@ -147,7 +147,7 @@ def test_get_requires_for_build_wheel_pure(fp):
 
 
 def test_get_requires_for_build_editable(fp):
-    expected = {"pathspec", "pyproject_metadata", "cmake>=3.15", *ninja}
+    expected = {"pathspec", "cmake>=3.15", *ninja}
     fp.register(
         [Path("cmake/path"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.14.0"}}',
@@ -156,7 +156,7 @@ def test_get_requires_for_build_editable(fp):
 
 
 def test_get_requires_for_build_editable_pure(fp):
-    expected = {"pathspec", "pyproject_metadata"}
+    expected = {"pathspec"}
     fp.register(
         [Path("cmake/path"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.14.0"}}',
diff --git a/tests/test_wheelfile_utils.py b/tests/test_wheelfile_utils.py
index 98bdbc7..cde150a 100644
--- a/tests/test_wheelfile_utils.py
+++ b/tests/test_wheelfile_utils.py
@@ -2,9 +2,9 @@ import stat
 import zipfile
 
 from packaging.tags import Tag
-from pyproject_metadata import StandardMetadata
 
 import scikit_build_core.build._wheelfile
+from scikit_build_core._vendor.pyproject_metadata import StandardMetadata
 
 
 def test_wheel_metadata() -> None: