# SPDX-License-Identifier: MIT

"""
This is pyproject_metadata, a library for working with metadata in the
pyproject.toml project table.

Example usage:

.. code-block:: python

   from pyproject_metadata import StandardMetadata

   metadata = StandardMetadata.from_pyproject(
       parsed_pyproject, allow_extra_keys=False, all_errors=True, metadata_version="2.3"
   )

   pkg_info = metadata.as_rfc822()
   with open("METADATA", "wb") as f:
       f.write(pkg_info.as_bytes())

   ep = metadata.entrypoints.copy()
   ep["console_scripts"] = metadata.scripts
   ep["gui_scripts"] = metadata.gui_scripts
   with open("entry_points.txt", "w", encoding="utf-8") as f:
       for group, entries in ep.items():
           if entries:
               print(f"[{group}]", file=f)
               for name, target in entries.items():
                   print(f"{name} = {target}", file=f)
               print(file=f)

"""

from __future__ import annotations

import contextlib
import copy
import dataclasses
import email.message
import email.policy
import itertools
import keyword
import os
import os.path
import pathlib
import re
import sys
import typing
import warnings

# Build backends may vendor this package, so all imports are relative.
from . import constants, pyproject
from .errors import ConfigurationError, ConfigurationWarning, ErrorCollector
from .project_table import to_project_table
from .pyproject import License, Readme

if typing.TYPE_CHECKING:
    from collections.abc import Generator, 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

    from .project_table import Dynamic, ProjectTable

import packaging.markers
import packaging.specifiers
import packaging.utils
import packaging.version

if sys.version_info < (3, 12, 4):
    RE_EOL_STR = re.compile(r"[\r\n]+")
    RE_EOL_BYTES = re.compile(rb"[\r\n]+")


__version__ = "0.12.1"

__all__ = [
    "ConfigurationError",
    "License",
    "RFC822Message",
    "RFC822Policy",
    "Readme",
    "StandardMetadata",
    "extras_build_system",
    "extras_project",
    "extras_top_level",
    "field_to_metadata",
]


def __dir__() -> list[str]:
    return __all__


def field_to_metadata(field: str) -> frozenset[str]:
    """
    Return the METADATA fields that correspond to a project field.
    """
    return frozenset(constants.PROJECT_TO_METADATA[field])


def extras_top_level(pyproject_table: Mapping[str, Any]) -> set[str]:
    """
    Return any extra keys in the top-level of the pyproject table.
    """
    return set(pyproject_table) - constants.KNOWN_TOPLEVEL_FIELDS


def extras_build_system(pyproject_table: Mapping[str, Any]) -> set[str]:
    """
    Return any extra keys in the build-system table.
    """
    return (
        set(pyproject_table.get("build-system", []))
        - constants.KNOWN_BUILD_SYSTEM_FIELDS
    )


def extras_project(pyproject_table: Mapping[str, Any]) -> set[str]:
    """
    Return any extra keys in the project table.
    """
    return set(pyproject_table.get("project", [])) - constants.KNOWN_PROJECT_FIELDS


@dataclasses.dataclass
class _SmartMessageSetter:
    """
    This provides a nice internal API for setting values in an Message to
    reduce boilerplate.

    If a value is None, do nothing.
    """

    message: email.message.Message

    def __setitem__(self, name: str, value: str | None) -> None:
        if value is None:
            return
        self.message[name] = value

    def set_payload(self, payload: str) -> None:
        self.message.set_payload(payload)


@dataclasses.dataclass
class _JSonMessageSetter:
    """
    This provides an API to build a JSON message output in the same way as the
    classic Message. Line breaks are preserved this way.
    """

    data: dict[str, str | list[str]]

    def __setitem__(self, name: str, value: str | None) -> None:
        name = name.lower()
        key = name.replace("-", "_")

        if value is None:
            return

        if name == "keywords":
            values = (x.strip() for x in value.split(","))
            self.data[key] = [x for x in values if x]
        elif name in constants.KNOWN_MULTIUSE:
            entry = self.data.setdefault(key, [])
            assert isinstance(entry, list)
            entry.append(value)
        else:
            self.data[key] = value

    def set_payload(self, payload: str) -> None:
        self["description"] = payload


class RFC822Policy(email.policy.EmailPolicy):
    """
    This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse``
    implementation that handles multiline values, and some nice defaults.
    """

    utf8 = True
    mangle_from_ = False
    max_line_length = 0

    def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
        """
        Require known headers, and replace newlines with spaces.
        """
        if name.lower() not in constants.KNOWN_METADATA_FIELDS:
            msg = f"Unknown field {name!r}"
            raise ConfigurationError(msg, key=name)
        size = len(name) + 2
        value = value.replace("\n", "\n" + " " * size)
        return (name, value)

    if sys.version_info < (3, 12, 4):
        # Work around Python bug https://github.com/python/cpython/issues/117313
        def _fold(
            self,
            name: str,
            value: Any,  # noqa: ANN401
            refold_binary: bool = False,  # noqa: FBT001, FBT002
        ) -> str:  # pragma: no cover
            if hasattr(value, "name"):
                return value.fold(policy=self)  # type: ignore[no-any-return]
            maxlen = self.max_line_length or sys.maxsize

            # this is from the library version, and it improperly breaks on chars like 0x0c, treating
            # them as 'form feed' etc.
            # we need to ensure that only CR/LF is used as end of line

            # this is a workaround which splits only on CR/LF characters
            if isinstance(value, bytes):
                lines = RE_EOL_BYTES.split(value)
            else:
                lines = RE_EOL_STR.split(value)

            refold = self.refold_source == "all" or (
                self.refold_source == "long"
                and (
                    (lines and len(lines[0]) + len(name) + 2 > maxlen)
                    or any(len(x) > maxlen for x in lines[1:])
                )
            )
            if refold or (
                refold_binary and email.policy._has_surrogates(value)  # type: ignore[attr-defined] # noqa: SLF001
            ):
                return self.header_factory(name, "".join(lines)).fold(policy=self)  # type: ignore[arg-type,no-any-return]
            return name + ": " + self.linesep.join(lines) + self.linesep  # type: ignore[arg-type]


def _validate_import_names(
    names: list[str], key: str, *, errors: ErrorCollector
) -> Generator[str, None, None]:
    """
    Return normalized names for comparisons.
    """
    if not isinstance(names, list):
        return
    for fullname in names:
        if not isinstance(fullname, str):
            continue
        name, semicolon, private = fullname.partition(";")
        if semicolon and private.strip() != "private":
            msg = "{key} contains an ending tag other than '; private', got {value!r}"
            errors.config_error(msg, key=key, value=fullname)
        name = name.rstrip()

        for ident in name.split("."):
            if not ident.isidentifier():
                msg = "{key} contains {value!r}, which is not a valid identifier"
                errors.config_error(msg, key=key, value=fullname)

            elif keyword.iskeyword(ident):
                msg = "{key} contains a Python keyword, which is not a valid import name, got {value!r}"
                errors.config_error(msg, key=key, value=fullname)

        yield name


def _validate_dotted_names(names: set[str], *, errors: ErrorCollector) -> None:
    """
    Check to make sure every name is accounted for. Takes the union of de-tagged names.
    """
    for name in names:
        for parent in itertools.accumulate(
            name.split(".")[:-1], lambda a, b: f"{a}.{b}"
        ):
            if parent not in names:
                msg = "{key} is missing {value!r}, but submodules are present elsewhere"
                errors.config_error(msg, key="project.import-namespaces", value=parent)


class RFC822Message(email.message.EmailMessage):
    """
    This is :class:`email.message.EmailMessage` with two small changes: it defaults to
    our `RFC822Policy`, and it correctly writes unicode when being called
    with `bytes()`.
    """

    def __init__(self) -> None:
        """
        Create a new message with RFC822Policy.
        """
        super().__init__(policy=RFC822Policy())

    def as_bytes(
        self,
        unixfrom: bool = False,  # noqa: FBT001, FBT002
        policy: email.policy.Policy | None = None,
    ) -> bytes:
        """
        Will always handle unicode encoding.
        """
        return self.as_string(unixfrom, policy=policy).encode("utf-8")


@dataclasses.dataclass
class StandardMetadata:
    """
    This class represents the standard metadata fields for a project. It can be
    used to read metadata from a pyproject.toml table, validate it, and write it
    to an RFC822 message or JSON.
    """

    name: str
    version: packaging.version.Version | None = None
    description: str | None = None
    license: License | str | None = None
    license_files: list[pathlib.Path] | 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 | None]] = dataclasses.field(default_factory=list)
    maintainers: list[tuple[str, str | None]] = 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)
    import_names: list[str] | None = None
    import_namespaces: list[str] | None = None
    dynamic: list[Dynamic] = dataclasses.field(default_factory=list)
    """
    This field contains the list of fields declared dynamic in ``project.dynamic``.
    A field that is both declared dynamic and explicitly set causes a parsing error.
    """

    dual_dynamic: set[str] = dataclasses.field(default_factory=set, repr=False)
    """
    Fields that are both declared in ``project.dynamic`` and given a static value
    in ``[project]`` (PEP 808). Emitting these as dynamic metadata requires
    metadata_version 2.6+.
    """
    dynamic_metadata: list[str] = dataclasses.field(default_factory=list)
    """
    This is a list of METADATA fields that can change in between SDist and wheel. Requires metadata_version 2.2+.
    """
    metadata_version: str | None = None
    """
    This is the target metadata version. If None, it will be computed as a minimum based on the fields set.
    """
    all_errors: bool = False
    """
    If True, all errors will be collected and raised in an ExceptionGroup.
    """

    def __post_init__(self) -> None:
        """
        Validate the fields on construction.
        """
        self.validate()

    @property
    def _dual_dynamic_metadata(self) -> set[str]:
        """
        Dual-dynamic fields (PEP 808) whose METADATA field is also marked in
        ``dynamic_metadata``. Only these require metadata_version 2.6; fields
        with no METADATA representation (scripts, gui-scripts, entry-points)
        never do, nor do dual fields unrelated to the marked Dynamic headers.
        """
        dynamic_metadata = {field.lower() for field in self.dynamic_metadata}
        return {
            field
            for field in self.dual_dynamic
            if {header.lower() for header in constants.PROJECT_TO_METADATA[field]}
            & dynamic_metadata
        }

    @property
    def auto_metadata_version(self) -> str:
        """
        This computes the metadata version based on the fields set in the object
        if ``metadata_version`` is None.
        """
        if self.metadata_version is not None:
            return self.metadata_version

        if self._dual_dynamic_metadata:
            return "2.6"
        if self.import_names is not None or self.import_namespaces is not None:
            return "2.5"
        if isinstance(self.license, str) or self.license_files is not None:
            return "2.4"
        if self.dynamic_metadata:
            return "2.2"
        return "2.1"

    @property
    def canonical_name(self) -> str:
        """
        Return the canonical name of the project.
        """
        return packaging.utils.canonicalize_name(self.name)

    @classmethod
    def from_pyproject(  # noqa: C901
        cls,
        data: Mapping[str, Any],
        project_dir: str | os.PathLike[str] = os.path.curdir,
        metadata_version: str | None = None,
        dynamic_metadata: list[str] | None = None,
        *,
        allow_extra_keys: bool | None = None,
        all_errors: bool = False,
    ) -> Self:
        """
        Read metadata from a pyproject.toml table. This is the main method for
        creating an instance of this class. It also supports two additional
        fields: ``allow_extra_keys`` to control what happens when extra keys are
        present in the pyproject table, and ``all_errors``, to  raise all errors
        in an ExceptionGroup instead of raising the first one.
        """
        error_collector = ErrorCollector(collect_errors=all_errors)
        if "project" not in data:
            msg = "Section {key} missing in pyproject.toml"
            error_collector.config_error(msg, key="project")
            error_collector.finalize("Failed to parse pyproject.toml")
            msg = "Unreachable code"  # pragma: no cover
            raise AssertionError(msg)  # pragma: no cover

        with error_collector.collect():
            to_project_table(dict(data), collect_errors=all_errors)

        project = data["project"]
        if not isinstance(project, dict):
            # In non-collecting mode, to_project_table already raised; this path
            # is only reachable with all_errors=True, where the type error was
            # collected and finalize is guaranteed to raise.
            error_collector.finalize("Failed to parse pyproject.toml")
            msg = "Unreachable code"  # pragma: no cover
            raise AssertionError(msg)  # noqa: TRY004  # pragma: no cover
        project = typing.cast("ProjectTable", project)

        project_dir = pathlib.Path(project_dir)

        if not allow_extra_keys:
            extra_keys = extras_project(data)
            if extra_keys:
                extra_keys_str = ", ".join(sorted(f"{k!r}" for k in extra_keys))
                msg = "Extra keys present in {key}: {extra_keys}"
                error_collector.config_error(
                    msg,
                    key="project",
                    extra_keys=extra_keys_str,
                    warn=allow_extra_keys is None,
                )

        dynamic = project.get("dynamic", [])

        dual_dynamic: set[str] = set()
        for field in dynamic:
            # ``dynamic`` is validated separately with error collection, so the
            # raw list may still contain values outside the Dynamic literal
            # (e.g. the invalid "name" or a non-string entry); skip anything
            # that is not a string and let that validation report it.
            if not isinstance(field, str):
                continue
            field_str: str = field
            if field_str in data["project"]:
                if field_str in constants.PROJECT_DYNAMIC_STATIC:
                    dual_dynamic.add(field_str)
                elif field_str != "name":
                    msg = 'Field {key} declared as dynamic in "project.dynamic" but is defined'
                    error_collector.config_error(msg, key=f"project.{field_str}")

        name = pyproject.ensure_str(project.get("name")) or "UNKNOWN"

        version: packaging.version.Version | None = packaging.version.Version("0.0.0")
        raw_version = project.get("version")
        if raw_version is not None:
            version_string = pyproject.ensure_str(raw_version)
            if version_string is not None:
                with contextlib.suppress(packaging.version.InvalidVersion):
                    version = (
                        packaging.version.Version(version_string)
                        if version_string
                        else None
                    )
        elif "version" in dynamic:
            # A dynamic version that has not been assigned yet is None; the
            # build backend is expected to set ``metadata.version`` before
            # writing the metadata. The 0.0.0 placeholder is only kept for the
            # error-collection paths (version present but invalid) so parsing
            # can continue under ``all_errors=True``.
            version = None
        else:
            msg = (
                "Field {key} missing and 'version' not specified in \"project.dynamic\""
            )
            error_collector.config_error(msg, key="project.version")

        # Description fills Summary, which cannot be multiline
        # However, throwing an error isn't backward compatible,
        # so leave it up to the users for now.
        project_description_raw = project.get("description")
        description = (
            pyproject.ensure_str(project_description_raw)
            if project_description_raw is not None
            else None
        )

        requires_python_raw = project.get("requires-python")
        requires_python = None
        if requires_python_raw is not None:
            requires_python_string = pyproject.ensure_str(requires_python_raw)
            if requires_python_string is not None:
                with contextlib.suppress(packaging.specifiers.InvalidSpecifier):
                    requires_python = packaging.specifiers.SpecifierSet(
                        requires_python_string
                    )

        authors = pyproject.ensure_people(project.get("authors", []))
        maintainers = pyproject.ensure_people(project.get("maintainers", []))
        license = pyproject.get_license(project, project_dir, error_collector)
        license_files = pyproject.get_license_files(
            project, project_dir, error_collector
        )
        readme = pyproject.get_readme(project, project_dir, error_collector)
        dependencies = pyproject.get_dependencies(project)
        optional_dependencies = pyproject.get_optional_dependencies(project)
        entrypoints = pyproject.get_entrypoints(project)

        self = None
        with error_collector.collect():
            self = cls(
                name=name,
                version=version,
                description=description,
                license=license,
                license_files=license_files,
                readme=readme,
                requires_python=requires_python,
                dependencies=dependencies,
                optional_dependencies=optional_dependencies,
                entrypoints=entrypoints,
                authors=authors,
                maintainers=maintainers,
                urls=project.get("urls", {}),
                classifiers=project.get("classifiers", []),
                keywords=project.get("keywords", []),
                scripts=project.get("scripts", {}),
                gui_scripts=project.get("gui-scripts", {}),
                import_names=project.get("import-names", None),
                import_namespaces=project.get("import-namespaces", None),
                dynamic=dynamic,
                dual_dynamic=dual_dynamic,
                dynamic_metadata=dynamic_metadata or [],
                metadata_version=metadata_version,
                all_errors=all_errors,
            )

        error_collector.finalize("Failed to parse pyproject.toml")
        assert self is not None
        return self

    def as_rfc822(self) -> RFC822Message:
        """
        Return an RFC822 message with the metadata.
        """
        message = RFC822Message()
        smart_message = _SmartMessageSetter(message)
        self._write_metadata(smart_message)
        return message

    def as_json(self) -> dict[str, str | list[str]]:
        """
        Return a JSON message with the metadata.
        """
        message: dict[str, str | list[str]] = {}
        smart_message = _JSonMessageSetter(message)
        self._write_metadata(smart_message)
        return message

    def validate(self, *, warn: bool = True) -> None:  # noqa: C901
        """
        Validate metadata for consistency and correctness.

        Will also produce warnings if ``warn`` is given. Respects
        ``all_errors``. This is called when loading a pyproject.toml, and when
        making metadata. Checks:

        - ``metadata_version`` is a known version or None
        - ``name`` is a valid project name
        - ``license_files`` can't be used with classic ``license``
        - License classifiers can't be used with SPDX license
        - ``description`` is a single line (warning)
        - Extra names in "project.optional-dependencies" should be valid (warning)
        - ``license`` is not an SPDX license expression if metadata_version >= 2.4 (warning)
        - License classifiers deprecated for metadata_version >= 2.4 (warning)
        - ``license`` is an SPDX license expression if metadata_version >= 2.4
        - ``license_files`` is supported only for metadata_version >= 2.4
        - ``project_url`` can't contain keys over 32 characters
        - ``import-name(paces)s`` is only supported on metadata_version >= 2.5
        - ``import-name(space)s`` must be valid names, optionally with ``; private``
        - ``import-names`` and ``import-namespaces`` cannot overlap.
        - A field that is both static and dynamic metadata requires metadata_version >= 2.6
        """
        errors = ErrorCollector(collect_errors=self.all_errors)

        if self.auto_metadata_version not in constants.KNOWN_METADATA_VERSIONS:
            msg = "The metadata_version must be one of {versions} or None (default)"
            errors.config_error(msg, versions=constants.KNOWN_METADATA_VERSIONS)

        try:
            packaging.utils.canonicalize_name(self.name, validate=True)
        except packaging.utils.InvalidName:
            msg = (
                "Invalid project name {name!r}. A valid name consists only of ASCII letters and "
                "numbers, period, underscore and hyphen. It must start and end with a letter or number"
            )
            errors.config_error(msg, key="project.name", name=self.name)

        if self.license_files is not None and isinstance(self.license, License):
            msg = '{key} must not be used when "project.license" is not a SPDX license expression'
            errors.config_error(msg, key="project.license-files")

        if isinstance(self.license, str) and any(
            c.startswith("License ::") for c in self.classifiers
        ):
            msg = "Setting {key} to an SPDX license expression is not compatible with 'License ::' classifiers"
            errors.config_error(msg, key="project.license")

        if warn:
            if self.description and "\n" in self.description:
                warnings.warn(
                    'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.',
                    ConfigurationWarning,
                    stacklevel=2,
                )
            if self.auto_metadata_version not in constants.PRE_SPDX_METADATA_VERSIONS:
                if isinstance(self.license, License):
                    warnings.warn(
                        'Set "project.license" to an SPDX license expression for metadata >= 2.4',
                        ConfigurationWarning,
                        stacklevel=2,
                    )
                elif any(c.startswith("License ::") for c in self.classifiers):
                    warnings.warn(
                        "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead",
                        ConfigurationWarning,
                        stacklevel=2,
                    )
            for extra in self.optional_dependencies:
                try:
                    packaging.utils.canonicalize_name(extra, validate=True)
                except packaging.utils.InvalidName:  # noqa: PERF203
                    warnings.warn(
                        f'Invalid extra name {extra!r} in "project.optional-dependencies". '
                        "A valid name consists only of ASCII letters and numbers, period, "
                        "underscore and hyphen. It must start and end with a letter or number",
                        ConfigurationWarning,
                        stacklevel=2,
                    )

        if (
            isinstance(self.license, str)
            and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS
        ):
            msg = "Setting {key} to an SPDX license expression is only supported when emitting metadata version >= 2.4"
            errors.config_error(msg, key="project.license")

        if (
            self.license_files is not None
            and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS
        ):
            msg = "{key} is only supported when emitting metadata version >= 2.4"
            errors.config_error(msg, key="project.license-files")

        for name in self.urls:
            if len(name) > 32:
                msg = "{key} names cannot be more than 32 characters long"
                errors.config_error(msg, key="project.urls", got=name)

        if (
            self.import_names is not None
            and self.auto_metadata_version in constants.PRE_2_5_METADATA_VERSIONS
        ):
            msg = "{key} is only supported when emitting metadata version >= 2.5"
            errors.config_error(msg, key="project.import-names")

        if (
            self.import_namespaces is not None
            and self.auto_metadata_version in constants.PRE_2_5_METADATA_VERSIONS
        ):
            msg = "{key} is only supported when emitting metadata version >= 2.5"
            errors.config_error(msg, key="project.import-namespaces")

        import_names = set(
            _validate_import_names(
                self.import_names or [], "import-names", errors=errors
            )
        )
        import_namespaces = set(
            _validate_import_names(
                self.import_namespaces or [], "import-namespaces", errors=errors
            )
        )
        in_both = import_names & import_namespaces
        if in_both:
            msg = "{key} overlaps with 'project.import-namespaces': {in_both}"
            errors.config_error(msg, key="project.import-names", in_both=in_both)

        _validate_dotted_names(import_names | import_namespaces, errors=errors)

        dual_dynamic_metadata = self._dual_dynamic_metadata
        if (
            dual_dynamic_metadata
            and self.auto_metadata_version in constants.PRE_2_6_METADATA_VERSIONS
        ):
            fields = ", ".join(sorted(dual_dynamic_metadata))
            msg = "Fields {fields} are declared as both static and dynamic, which requires metadata_version >= 2.6"
            errors.config_error(msg, key="project.dynamic", fields=fields)

        errors.finalize("Metadata validation failed")

    def _write_metadata(  # noqa: C901
        self, smart_message: _SmartMessageSetter | _JSonMessageSetter
    ) -> None:
        """
        Write the metadata to the message. Handles JSON or Message.
        """
        errors = ErrorCollector(collect_errors=self.all_errors)
        with errors.collect():
            self.validate(warn=False)

        smart_message["Metadata-Version"] = self.auto_metadata_version
        smart_message["Name"] = self.name
        if not self.version:
            msg = "Field {key} missing"
            errors.config_error(msg, key="project.version")
        smart_message["Version"] = str(self.version)
        # skip 'Platform'
        # skip 'Supported-Platform'
        if self.description:
            smart_message["Summary"] = self.description
        smart_message["Keywords"] = ",".join(self.keywords) or None
        # skip 'Home-page'
        # skip 'Download-URL'
        smart_message["Author"] = _name_list(self.authors)
        smart_message["Author-Email"] = _email_list(self.authors)
        smart_message["Maintainer"] = _name_list(self.maintainers)
        smart_message["Maintainer-Email"] = _email_list(self.maintainers)

        if isinstance(self.license, License):
            smart_message["License"] = self.license.text
        elif isinstance(self.license, str):
            smart_message["License-Expression"] = self.license

        if self.license_files is not None:
            for license_file in sorted(set(self.license_files)):
                smart_message["License-File"] = license_file.as_posix()
        elif (
            self.auto_metadata_version not in constants.PRE_SPDX_METADATA_VERSIONS
            and isinstance(self.license, License)
            and self.license.file
        ):
            smart_message["License-File"] = self.license.file.as_posix()

        for classifier in self.classifiers:
            smart_message["Classifier"] = classifier
        # skip 'Provides-Dist'
        # skip 'Obsoletes-Dist'
        # skip 'Requires-External'
        for name, url in self.urls.items():
            smart_message["Project-URL"] = f"{name}, {url}"
        if self.requires_python:
            smart_message["Requires-Python"] = str(self.requires_python)
        for dep in self.dependencies:
            smart_message["Requires-Dist"] = str(dep)
        for extra, requirements in self.optional_dependencies.items():
            norm_extra = extra.replace(".", "-").replace("_", "-").lower()
            smart_message["Provides-Extra"] = norm_extra
            for requirement in requirements:
                smart_message["Requires-Dist"] = str(
                    _build_extra_req(norm_extra, requirement)
                )
        if self.readme:
            assert self.readme.content_type  # verified earlier
            smart_message["Description-Content-Type"] = self.readme.content_type
            smart_message.set_payload(self.readme.text)
        for import_name in self.import_names or []:
            smart_message["Import-Name"] = import_name
        for import_namespace in self.import_namespaces or []:
            smart_message["Import-Namespace"] = import_namespace
        # Special case for empty import-names
        if self.import_names is not None and not self.import_names:
            smart_message["Import-Name"] = ""
        # Core Metadata 2.2
        if self.auto_metadata_version != "2.1":
            for field in self.dynamic_metadata:
                if field.lower() in {"name", "version", "dynamic"}:
                    msg = "Metadata field {field!r} cannot be declared dynamic"
                    errors.config_error(msg, field=field)
                if field.lower() not in constants.KNOWN_METADATA_FIELDS:
                    msg = "Unknown metadata field {field!r} cannot be declared dynamic"
                    errors.config_error(msg, field=field)
                smart_message["Dynamic"] = field

        errors.finalize("Failed to write metadata")


def _name_list(people: list[tuple[str, str | None]]) -> str | None:
    """
    Build a comma-separated list of names.
    """
    return ", ".join(name for name, email_ in people if not email_) or None


_DISPLAY_NAME_NEEDS_QUOTING = re.compile(r'[][\\()<>@,:;".]')
_QUOTED_STRING_ESCAPE = re.compile(r'([\\"])')


def _format_address(name: str, addr: str) -> str:
    if _DISPLAY_NAME_NEEDS_QUOTING.search(name):
        quoted = _QUOTED_STRING_ESCAPE.sub(r"\\\1", name)
        return f'"{quoted}" <{addr}>'
    return f"{name} <{addr}>"


def _email_list(people: list[tuple[str, str | None]]) -> str | None:
    """
    Build a comma-separated list of emails.
    """
    return (
        ", ".join(_format_address(name, _email) for name, _email in people if _email)
        or None
    )


def _build_extra_req(
    extra: str,
    requirement: Requirement,
) -> Requirement:
    """
    Build a new requirement with an extra marker.
    """
    requirement = copy.copy(requirement)
    if requirement.marker:
        if "or" in requirement.marker._markers:  # noqa: SLF001
            requirement.marker = packaging.markers.Marker(
                f"({requirement.marker}) and extra == {extra!r}"
            )
        else:
            requirement.marker = packaging.markers.Marker(
                f"{requirement.marker} and extra == {extra!r}"
            )
    else:
        requirement.marker = packaging.markers.Marker(f"extra == {extra!r}")
    return requirement
