# SPDX-License-Identifier: MIT

from __future__ import annotations

import contextlib
import pathlib
import re
import shutil
import sys
import textwrap
import warnings

import packaging.specifiers
import packaging.version
import pytest

if sys.version_info < (3, 11):
    import tomli as tomllib
else:
    import tomllib

import pyproject_metadata
import pyproject_metadata.constants

DIR = pathlib.Path(__file__).parent.resolve()


try:
    import exceptiongroup
except ImportError:
    exceptiongroup = None  # type: ignore[assignment]


@pytest.fixture(params=pyproject_metadata.constants.KNOWN_METADATA_VERSIONS)
def metadata_version(request: pytest.FixtureRequest) -> str:
    return request.param  # type: ignore[no-any-return]


@pytest.fixture(params=["one_error", "all_errors", "exceptiongroup"])
def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> bool:
    param: str = request.param
    if param == "exceptiongroup":
        if exceptiongroup is None:
            pytest.skip("exceptiongroup is not installed")
        monkeypatch.setattr(
            pyproject_metadata.errors, "ExceptionGroup", exceptiongroup.ExceptionGroup
        )
    return param != "one_error"


@pytest.mark.parametrize(
    ("data", "error"),
    [
        pytest.param(
            "",
            'Section "project" missing in pyproject.toml',
            id="Missing project section",
        ),
        pytest.param(
            """
                [project]
                name = true
                version = "0.1.0"
            """,
            'Field "project.name" has an invalid type, expecting str (got bool)',
            id="Invalid name type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                not-real-key = true
            """,
            "Extra keys present in \"project\": 'not-real-key'",
            id="Invalid project key",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                dynamic = [
                    "name",
                ]
            """,
            'Field "project.dynamic[0]" expected one of',
            id="Unsupported field in project.dynamic",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                dynamic = [
                    "dependencies",
                    3,
                ]
            """,
            'Field "project.dynamic[1]" expected one of',
            id="Unsupported type in project.dynamic",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = true
            """,
            'Field "project.version" has an invalid type, expecting str (got bool)',
            id="Invalid version type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
            """,
            'Field "project.version" missing and \'version\' not specified in "project.dynamic"',
            id="Missing version",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0-extra"
            """,
            "Field \"project.version\" is an invalid PEP 440 version string (got '0.1.0-extra')",
            id="Invalid version value",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = true
            """,
            'Field "project.license" does not match any of: LicenseTable | str (got bool)',
            id="License invalid type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = {}
            """,
            'Field "project.license" must have exactly one of "text" or "file" keys',
            id="Missing license keys",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = { file = "...", text = "..." }
            """,
            ('Field "project.license" must have exactly one of "text" or "file" keys'),
            id="Both keys for license",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = { text = "...", made-up = ":(" }
            """,
            'Field "project.license" contains unexpected keys: "made-up"',
            id="Got made-up license field",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = { file = true }
            """,
            'Field "project.license.file" has an invalid type, expecting str (got bool)',
            id="Invalid type for license.file",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = { text = true }
            """,
            'Field "project.license.text" has an invalid type, expecting str (got bool)',
            id="Invalid type for license.text",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = { file = "this-file-does-not-exist" }
            """,
            "License file not found ('this-file-does-not-exist')",
            id="License file not present",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = true
            """,
            (
                'Field "project.readme" does not match any of: str | ReadmeTable (got bool)'
            ),
            id="Invalid readme type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { content-type = "text/markdown" }
            """,
            'Field "project.readme" must have exactly one of "file" or "text" keys',
            id="Missing file/text in readme table",
        ),
        pytest.param(
            """
                [project]
                name = 'test'
                version = "0.1.0"
                readme = "README.jpg"
            """,
            "Could not infer content type for readme file 'README.jpg'",
            id="Unsupported filename in readme",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { file = "...", text = "...", content-type = "text/markdown" }
            """,
            ('Field "project.readme" must have exactly one of "file" or "text" keys'),
            id="Both readme fields",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { text="...", content-type="text/markdown", made-up = ":(" }
            """,
            'Field "project.readme" contains unexpected keys: "made-up"',
            id="Unexpected field in readme",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { file = true, content-type = "text/markdown" }
            """,
            'Field "project.readme.file" has an invalid type, expecting str (got bool)',
            id="Invalid type for readme.file",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { text = true, content-type = "text/markdown" }
            """,
            'Field "project.readme.text" has an invalid type, expecting str (got bool)',
            id="Invalid type for readme.text",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { file = "this-file-does-not-exist", content-type = "..." }
            """,
            "Readme file not found ('this-file-does-not-exist')",
            id="Readme file not present",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { file = "README.md" }
            """,
            'Field "project.readme" is missing required key "content-type"',
            id="Missing content-type for readme (file only)",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { file = 'README.md', content-type = true }
            """,
            'Field "project.readme.content-type" has an invalid type, expecting str (got bool)',
            id="Wrong content-type type for readme",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { text = "..." }
            """,
            'Field "project.readme" is missing required key "content-type"',
            id="Missing content-type for readme (text only)",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                description = true
            """,
            'Field "project.description" has an invalid type, expecting str (got bool)',
            id="Invalid description type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                dependencies = "some string!"
            """,
            'Field "project.dependencies" has an invalid type, expecting list[str] (got str)',
            id="Invalid dependencies type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                dependencies = [
                    99,
                ]
            """,
            'Field "project.dependencies[0]" has an invalid type, expecting str (got int)',
            id="Invalid dependencies item type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                dependencies = [
                    "definitely not a valid PEP 508 requirement!",
                ]
            """,
            (
                'Field "project.dependencies[0]" is an invalid PEP 508 requirement '
                "string 'definitely not a valid PEP 508 requirement!' "
            ),
            id="Invalid dependencies item",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                optional-dependencies = true
            """,
            (
                'Field "project.optional-dependencies" has an invalid type, '
                "expecting dict[str, list[str]] (got bool)"
            ),
            id="Invalid optional-dependencies type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.optional-dependencies]
                test = "some string!"
            """,
            (
                'Field "project.optional-dependencies.test" has an invalid type, '
                "expecting list[str] (got str)"
            ),
            id="Invalid optional-dependencies not list",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.optional-dependencies]
                test = [
                    true,
                ]
            """,
            (
                'Field "project.optional-dependencies.test[0]" has an invalid type, '
                "expecting str (got bool)"
            ),
            id="Invalid optional-dependencies item type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.optional-dependencies]
                test = [
                    "definitely not a valid PEP 508 requirement!",
                ]
            """,
            (
                'Field "project.optional-dependencies.test[0]" is an invalid '
                "PEP 508 requirement string 'definitely not a valid PEP 508 requirement!' "
            ),
            id="Invalid optional-dependencies item",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                requires-python = true
            """,
            'Field "project.requires-python" has an invalid type, expecting str (got bool)',
            id="Invalid requires-python type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                requires-python = "3.8"
            """,
            "Field \"project.requires-python\" is an invalid Python version specifier string (got '3.8')",
            id="Invalid requires-python value",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                keywords = "some string!"
            """,
            'Field "project.keywords" has an invalid type, expecting list[str] (got str)',
            id="Invalid keywords type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                keywords = [3]
            """,
            'Field "project.keywords[0]" has an invalid type, expecting str (got int)',
            id="Invalid keyword type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                keywords = [
                    true,
                ]
            """,
            'Field "project.keywords[0]" has an invalid type, expecting str (got bool)',
            id="Invalid keywords item type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                authors = {}
            """,
            (
                'Field "project.authors" has an invalid type, expecting list[ContactTable] (got dict)'
            ),
            id="Invalid authors type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                authors = [
                    true,
                ]
            """,
            (
                'Field "project.authors[0]" has an invalid type, expecting ContactTable (got bool)'
            ),
            id="Invalid authors item type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                maintainers = {}
            """,
            (
                'Field "project.maintainers" has an invalid type, expecting list[ContactTable] (got dict)'
            ),
            id="Invalid maintainers type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                maintainers = [
                    10
                ]
            """,
            (
                'Field "project.maintainers[0]" has an invalid type, expecting ContactTable (got int)'
            ),
            id="Invalid maintainers item type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                maintainers = [
                    {"name" = 12}
                ]
            """,
            (
                'Field "project.maintainers[0].name" has an invalid type, expecting str (got int)'
            ),
            id="Invalid maintainers nested type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                maintainers = [
                    {"name" = "me", "other" = "you"}
                ]
            """,
            ('Field "project.maintainers[0]" contains unexpected keys: "other"'),
            id="Invalid maintainers nested type with extra key",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                maintainers = [
                    {name = "me", email = "one@two.com"},
                    {}
                ]
            """,
            (
                'Field "project.maintainers[1]" must have at least one of "name" or "email" keys'
            ),
            id="Invalid maintainers empty",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                classifiers = "some string!"
            """,
            'Field "project.classifiers" has an invalid type, expecting list[str] (got str)',
            id="Invalid classifiers type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                classifiers = [
                    true,
                ]
            """,
            'Field "project.classifiers[0]" has an invalid type, expecting str (got bool)',
            id="Invalid classifiers item type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.urls]
                homepage = true
            """,
            'Field "project.urls.homepage" has an invalid type, expecting str (got bool)',
            id="Invalid urls homepage type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.urls]
                Documentation = true
            """,
            'Field "project.urls.Documentation" has an invalid type, expecting str (got bool)',
            id="Invalid urls documentation type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.urls]
                repository = true
            """,
            'Field "project.urls.repository" has an invalid type, expecting str (got bool)',
            id="Invalid urls repository type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.urls]
                "I am really really too long for this place" = "url"
            """,
            "\"project.urls\" names cannot be more than 32 characters long (got 'I am really really too long for this place')",
            id="URL name too long",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.urls]
                changelog = true
            """,
            'Field "project.urls.changelog" has an invalid type, expecting str (got bool)',
            id="Invalid urls changelog type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                scripts = []
            """,
            'Field "project.scripts" has an invalid type, expecting dict[str, str] (got list)',
            id="Invalid scripts type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                gui-scripts = []
            """,
            'Field "project.gui-scripts" has an invalid type, expecting dict[str, str] (got list)',
            id="Invalid gui-scripts type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                entry-points = []
            """,
            (
                'Field "project.entry-points" has an invalid type, '
                "expecting dict[str, dict[str, str]] (got list)"
            ),
            id="Invalid entry-points type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                entry-points = { section = "something" }
            """,
            (
                'Field "project.entry-points.section" has an invalid type, '
                "expecting dict[str, str] (got str)"
            ),
            id="Invalid entry-points section type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.entry-points.section]
                entrypoint = []
            """,
            'Field "project.entry-points.section.entrypoint" has an invalid type, expecting str (got list)',
            id="Invalid entry-points entrypoint type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                [project.entry-points.bad-name]
            """,
            (
                'Field "project.entry-points" has an invalid key, expecting a key containing only '
                "alphanumeric, underscore, or dot characters (got 'bad-name')"
            ),
            id="Invalid entry-points name",
        ),
        pytest.param(
            """
                [project]
                name = ".test"
                version = "0.1.0"
            """,
            (
                "Invalid project name '.test'. A valid name consists only of ASCII letters and "
                "numbers, period, underscore and hyphen. It must start and end with a letter or number"
            ),
            id="Invalid project name",
        ),
        # both license files and classic license are not allowed
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license-files = []
                license.text = 'stuff'
            """,
            '"project.license-files" must not be used when "project.license" is not a SPDX license expression',
            id="Both license files and classic license",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license-files = ['../LICENSE']
            """,
            "'../LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory",
            id="Parent license-files glob",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license-files = [12]
            """,
            'Field "project.license-files[0]" has an invalid type, expecting str (got int)',
            id="Parent license-files invalid type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license-files = ['this', 12]
            """,
            'Field "project.license-files[1]" has an invalid type, expecting str (got int)',
            id="Parent license-files invalid type",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license-files = ['/LICENSE']
            """,
            "'/LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory",
            id="Absolute license-files glob",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license-files = ['foo/../../LICENSE']
            """,
            "'foo/../../LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory",
            id="Interior dotdot license-files glob",
        ),
        pytest.param(
            r"""
                [project]
                name = "test"
                version = "0.1.0"
                license-files = ['C:\LICENSE']
            """,
            "'C:\\\\LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory",
            id="Windows absolute license-files glob",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = 'MIT'
                classifiers = ['License :: OSI Approved :: MIT License']
            """,
            "Setting \"project.license\" to an SPDX license expression is not compatible with 'License ::' classifiers",
            id="SPDX license and License trove classifiers",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-names = ["is"]
            """,
            "\"import-names\" contains a Python keyword, which is not a valid import name, got 'is'",
            id="Setting import-names to keyword",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-namespaces = ["from"]
            """,
            "\"import-namespaces\" contains a Python keyword, which is not a valid import name, got 'from'",
            id="Setting import-namespaces to keyword",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-names = ["2two"]
            """,
            "\"import-names\" contains '2two', which is not a valid identifier",
            id="Setting import-names invalid identifier",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-namespaces = ["3"]
            """,
            "\"import-namespaces\" contains '3', which is not a valid identifier",
            id="Setting import-namespaces to invalid identifier",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-names = ["one", "two"]
                import-namespaces = ["one", "three"]
            """,
            "\"project.import-names\" overlaps with 'project.import-namespaces': {'one'}",
            id="Matching entry in import-names and import-namespaces",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-names = ["one; private", "two"]
                import-namespaces = ["one", "three    ;   private"]
            """,
            "\"project.import-names\" overlaps with 'project.import-namespaces': {'one'}",
            id="Matching entry in import-names and import-namespaces with private tags",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-names = ["one.two"]
            """,
            "\"project.import-namespaces\" is missing 'one', but submodules are present elsewhere",
            id="Matching entry in import-names and import-namespaces",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-names = [2]
            """,
            '"project.import-names[0]" has an invalid type, expecting str (got int)',
            id="import-names invalid type",
        ),
    ],
)
def test_load(
    data: str, error: str, monkeypatch: pytest.MonkeyPatch, all_errors: bool
) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata")
    if not all_errors:
        with pytest.raises(
            pyproject_metadata.ConfigurationError, match=re.escape(error)
        ):
            pyproject_metadata.StandardMetadata.from_pyproject(
                tomllib.loads(textwrap.dedent(data)),
                allow_extra_keys=False,
            )
    else:
        with warnings.catch_warnings():
            warnings.simplefilter(
                action="ignore", category=pyproject_metadata.errors.ConfigurationWarning
            )
            with pytest.raises(pyproject_metadata.errors.ExceptionGroup) as execinfo:
                pyproject_metadata.StandardMetadata.from_pyproject(
                    tomllib.loads(textwrap.dedent(data)),
                    allow_extra_keys=False,
                    all_errors=True,
                )
        exceptions = execinfo.value.exceptions
        args = [e.args[0] for e in exceptions]
        assert len(args) == 1
        assert error in args[0]
        assert "Failed to parse pyproject.toml" in repr(execinfo.value)


@pytest.mark.parametrize(
    ("data", "errors"),
    [
        pytest.param(
            "[project]",
            [
                'Field "project.name" is required if "project" is present',
                'Field "project.version" missing and \'version\' not specified in "project.dynamic"',
            ],
            id="Missing project name",
        ),
        pytest.param(
            """
                [project]
                name = true
                version = "0.1.0"
                dynamic = [
                    "name",
                ]
            """,
            [
                'Field "project.name" has an invalid type, expecting str (got bool)',
                "Field \"project.dynamic[0]\" expected one of 'authors', 'classifiers', "
                "'dependencies', 'description', 'entry-points', 'gui-scripts', "
                "'import-names', 'import-namespaces', 'keywords', 'license', 'license-files', "
                "'maintainers', 'optional-dependencies', 'readme', 'requires-python', "
                "'scripts', 'urls', 'version' (got 'name')",
            ],
            id="Unsupported field in project.dynamic",
        ),
        pytest.param(
            """
                [project]
                name = true
                version = "0.1.0"
                dynamic = [
                    3,
                ]
            """,
            [
                'Field "project.name" has an invalid type, expecting str (got bool)',
                "Field \"project.dynamic[0]\" expected one of 'authors', 'classifiers', "
                "'dependencies', 'description', 'entry-points', 'gui-scripts', "
                "'import-names', 'import-namespaces', 'keywords', 'license', 'license-files', "
                "'maintainers', 'optional-dependencies', 'readme', 'requires-python', "
                "'scripts', 'urls', 'version' (got 3)",
            ],
            id="Unsupported type in project.dynamic",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                dynamic = [
                    [ "nested" ],
                ]
            """,
            [
                "Field \"project.dynamic[0]\" expected one of 'authors', 'classifiers', "
                "'dependencies', 'description', 'entry-points', 'gui-scripts', "
                "'import-names', 'import-namespaces', 'keywords', 'license', 'license-files', "
                "'maintainers', 'optional-dependencies', 'readme', 'requires-python', "
                "'scripts', 'urls', 'version' (got ['nested'])",
            ],
            id="Unhashable type in project.dynamic",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                readme = { }
            """,
            [
                'Field "project.readme" must have exactly one of "file" or "text" keys',
                'Field "project.readme" is missing required key "content-type"',
            ],
            id="Empty readme table",
        ),
        pytest.param(
            """
                [project]
                name = 'test'
                version = "0.1.0"
                readme = "README.jpg"
                license-files = [12]
            """,
            [
                'Field "project.license-files[0]" has an invalid type, expecting str (got int)',
                "Could not infer content type for readme file 'README.jpg'",
            ],
            id="Unsupported filename in readme",
        ),
        pytest.param(
            """
                [project]
                name = 'test'
                version = "0.1.0"
                license = { made-up = ":(" }
            """,
            [
                'Field "project.license" must have exactly one of "text" or "file" keys',
                'Field "project.license" contains unexpected keys: "made-up"',
            ],
            id="Only made up license field",
        ),
        pytest.param(
            """
                [project]
                name = 'test'
                version = "0.1.0"
                readme = "README.jpg"
                license-files = [12]
                entry-points.bad-name = {}
                other-entry = {}
                not-valid = true
            """,
            [
                'Field "project.license-files[0]" has an invalid type, expecting str (got int)',
                "Field \"project.entry-points\" has an invalid key, expecting a key containing only alphanumeric, underscore, or dot characters (got 'bad-name')",
                "Extra keys present in \"project\": 'not-valid', 'other-entry'",
                "Could not infer content type for readme file 'README.jpg'",
            ],
            id="Four errors including extra keys",
        ),
        pytest.param(
            """
                [project]
                name = 'test'
                version = "0.1.0"
                import-names = ["test", "other"]
                import-namespaces = ["other.one.two", "invalid name", "not; public"]
            """,
            [
                "\"import-namespaces\" contains 'invalid name', which is not a valid identifier",
                "\"import-namespaces\" contains an ending tag other than '; private', got 'not; public'",
                "\"import-namespaces\" contains a Python keyword, which is not a valid import name, got 'not; public'",
                "\"project.import-namespaces\" is missing 'other.one', but submodules are present elsewhere",
            ],
            id="Multiple errors related to names/namespaces",
        ),
        pytest.param(
            """
                [project]
                name = 'test'
                version = "0.1.0"
                license-files = ['../LICENSE', '/LICENSE', 'foo/../../LICENSE']
            """,
            [
                "'../LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory",
                "'/LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory",
                "'foo/../../LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory",
            ],
            id="Multiple bad license-files globs all reported",
        ),
    ],
)
def test_load_multierror(
    data: str, errors: list[str], monkeypatch: pytest.MonkeyPatch, all_errors: bool
) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata")
    if not all_errors:
        with pytest.raises(
            pyproject_metadata.ConfigurationError, match=re.escape(errors[0])
        ):
            pyproject_metadata.StandardMetadata.from_pyproject(
                tomllib.loads(textwrap.dedent(data)),
                allow_extra_keys=False,
            )
    else:
        with warnings.catch_warnings():
            warnings.simplefilter(
                action="ignore", category=pyproject_metadata.errors.ConfigurationWarning
            )
            with pytest.raises(pyproject_metadata.errors.ExceptionGroup) as execinfo:
                pyproject_metadata.StandardMetadata.from_pyproject(
                    tomllib.loads(textwrap.dedent(data)),
                    allow_extra_keys=False,
                    all_errors=True,
                )
        exceptions = execinfo.value.exceptions
        args = [e.args[0] for e in exceptions]
        assert len(args) == len(errors)
        assert args == errors
        assert "Failed to parse pyproject.toml" in repr(execinfo.value)


def test_load_non_table_project(all_errors: bool) -> None:
    error = 'Field "project" has an invalid type, expecting ProjectTable (got list)'
    data = {"project": ["oops"]}
    if not all_errors:
        with pytest.raises(
            pyproject_metadata.ConfigurationError, match=re.escape(error)
        ):
            pyproject_metadata.StandardMetadata.from_pyproject(data)
    else:
        with pytest.raises(pyproject_metadata.errors.ExceptionGroup) as execinfo:
            pyproject_metadata.StandardMetadata.from_pyproject(data, all_errors=True)
        exceptions = execinfo.value.exceptions
        args = [e.args[0] for e in exceptions]
        assert args == [error]
        assert "Failed to parse pyproject.toml" in repr(execinfo.value)


@pytest.mark.parametrize(
    ("data", "error", "metadata_version"),
    [
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license = 'MIT'
            """,
            'Setting "project.license" to an SPDX license expression is only supported when emitting metadata version >= 2.4',
            "2.3",
            id="SPDX with metadata_version 2.3",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license-files = ['README.md']
            """,
            '"project.license-files" is only supported when emitting metadata version >= 2.4',
            "2.3",
            id="license-files with metadata_version 2.3",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-names = ['one']
            """,
            '"project.import-names" is only supported when emitting metadata version >= 2.5',
            "2.4",
            id="import-names with metadata_version 2.4",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                import-namespaces = ['one']
            """,
            '"project.import-namespaces" is only supported when emitting metadata version >= 2.5',
            "2.4",
            id="import-names with metadata_version 2.4",
        ),
    ],
)
def test_load_with_metadata_version(
    data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata")
    with pytest.raises(pyproject_metadata.ConfigurationError, match=re.escape(error)):
        pyproject_metadata.StandardMetadata.from_pyproject(
            tomllib.loads(textwrap.dedent(data)), metadata_version=metadata_version
        )


@pytest.mark.parametrize(
    ("data", "error", "metadata_version"),
    [
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                license.text = 'MIT'
            """,
            'Set "project.license" to an SPDX license expression for metadata >= 2.4',
            "2.4",
            id="Classic license with metadata 2.4",
        ),
        pytest.param(
            """
                [project]
                name = "test"
                version = "0.1.0"
                classifiers = ['License :: OSI Approved :: MIT License']
            """,
            "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead",
            "2.4",
            id="License trove classifiers with metadata 2.4",
        ),
    ],
)
def test_load_with_metadata_version_warnings(
    data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata")
    with pytest.warns(
        pyproject_metadata.errors.ConfigurationWarning, match=re.escape(error)
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            tomllib.loads(textwrap.dedent(data)), metadata_version=metadata_version
        )


@pytest.mark.parametrize("after_rfc", [False, True])
def test_value(after_rfc: bool, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata")
    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))

    if after_rfc:
        metadata.as_rfc822()

    assert metadata.dynamic == []
    assert metadata.name == "full_metadata"
    assert metadata.canonical_name == "full-metadata"
    assert metadata.version == packaging.version.Version("3.2.1")
    assert metadata.requires_python == packaging.specifiers.Specifier(">=3.8")
    assert isinstance(metadata.license, pyproject_metadata.License)
    assert metadata.license.file is None
    assert metadata.license.text == "some license text"
    assert isinstance(metadata.readme, pyproject_metadata.Readme)
    assert metadata.readme.file == pathlib.Path("README.md")
    assert metadata.readme.text == pathlib.Path("README.md").read_text(encoding="utf-8")
    assert metadata.readme.content_type == "text/markdown"
    assert metadata.description == "A package with all the metadata :)"
    assert metadata.authors == [
        ("Unknown", "example@example.com"),
        ("Example!", None),
    ]
    assert metadata.maintainers == [
        ("Emailless", None),
        ("Other Example", "other@example.com"),
    ]
    assert metadata.keywords == ["trampolim", "is", "interesting"]
    assert metadata.classifiers == [
        "Development Status :: 4 - Beta",
        "Programming Language :: Python",
    ]
    assert metadata.urls == {
        "changelog": "github.com/some/repo/blob/master/CHANGELOG.rst",
        "documentation": "readthedocs.org",
        "homepage": "example.com",
        "repository": "github.com/some/repo",
    }
    assert metadata.entrypoints == {
        "custom": {
            "full-metadata": "full_metadata:main_custom",
        },
    }
    assert metadata.scripts == {
        "full-metadata": "full_metadata:main_cli",
    }
    assert metadata.gui_scripts == {
        "full-metadata-gui": "full_metadata:main_gui",
    }
    assert list(map(str, metadata.dependencies)) == [
        "dependency1",
        "dependency2>1.0.0",
        "dependency3[extra]",
        'dependency4; os_name != "nt"',
        'dependency5[other-extra]>1.0; os_name == "nt"',
    ]
    assert list(metadata.optional_dependencies.keys()) == ["test"]
    assert list(map(str, metadata.optional_dependencies["test"])) == [
        "test_dependency",
        "test_dependency[test_extra]",
        'test_dependency[test_extra2]>3.0; os_name == "nt"',
    ]


@pytest.mark.parametrize("after_rfc", [False, True])
def test_value_25(after_rfc: bool, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/metadata-2.5")
    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))

    if after_rfc:
        metadata.as_rfc822()

    assert metadata.auto_metadata_version == "2.5"

    assert isinstance(metadata.license, str)
    assert metadata.license == "MIT"
    assert metadata.license_files == [pathlib.Path("LICENSE")]

    assert metadata.import_names == ["metadata25"]
    assert metadata.import_namespaces is None


def test_read_license(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata2")
    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))

    assert isinstance(metadata.license, pyproject_metadata.License)
    assert metadata.license.file == pathlib.Path("LICENSE")
    assert metadata.license.text == "Some license! 👋\n"


@pytest.mark.parametrize(
    ("package", "content_type"),
    [
        ("full-metadata", "text/markdown"),
        ("full-metadata2", "text/x-rst"),
    ],
)
def test_readme_content_type(
    package: str, content_type: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.chdir(DIR / "packages" / package)
    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))

    assert isinstance(metadata.readme, pyproject_metadata.Readme)
    assert metadata.readme.content_type == content_type


def test_readme_content_type_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/unknown-readme-type")
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape(
            "Could not infer content type for readme file 'README.just-made-this-up-now'"
        ),
    ), open("pyproject.toml", "rb") as f:
        pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))


def test_as_json(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata")

    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
    core_metadata = metadata.as_json()

    assert core_metadata == {
        "author": "Example!",
        "author_email": "Unknown <example@example.com>",
        "classifier": [
            "Development Status :: 4 - Beta",
            "Programming Language :: Python",
        ],
        "description": "some readme 👋\n",
        "description_content_type": "text/markdown",
        "keywords": ["trampolim", "is", "interesting"],
        "license": "some license text",
        "maintainer": "Emailless",
        "maintainer_email": "Other Example <other@example.com>",
        "metadata_version": "2.1",
        "name": "full_metadata",
        "project_url": [
            "homepage, example.com",
            "documentation, readthedocs.org",
            "repository, github.com/some/repo",
            "changelog, github.com/some/repo/blob/master/CHANGELOG.rst",
        ],
        "provides_extra": ["test"],
        "requires_dist": [
            "dependency1",
            "dependency2>1.0.0",
            "dependency3[extra]",
            'dependency4; os_name != "nt"',
            'dependency5[other-extra]>1.0; os_name == "nt"',
            'test_dependency; extra == "test"',
            'test_dependency[test_extra]; extra == "test"',
            'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"',
        ],
        "requires_python": ">=3.8",
        "summary": "A package with all the metadata :)",
        "version": "3.2.1",
    }


def test_readme_text() -> None:
    pyproject = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "foo",
                "version": "1.2.3",
                "readme": {"text": "onetwothree", "content-type": "text/plain"},
            }
        }
    )
    assert pyproject.readme
    assert pyproject.readme.text == "onetwothree"


def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/full-metadata")

    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
    core_metadata = metadata.as_rfc822()
    assert core_metadata.items() == [
        ("Metadata-Version", "2.1"),
        ("Name", "full_metadata"),
        ("Version", "3.2.1"),
        ("Summary", "A package with all the metadata :)"),
        ("Keywords", "trampolim,is,interesting"),
        ("Author", "Example!"),
        ("Author-Email", "Unknown <example@example.com>"),
        ("Maintainer", "Emailless"),
        ("Maintainer-Email", "Other Example <other@example.com>"),
        ("License", "some license text"),
        ("Classifier", "Development Status :: 4 - Beta"),
        ("Classifier", "Programming Language :: Python"),
        ("Project-URL", "homepage, example.com"),
        ("Project-URL", "documentation, readthedocs.org"),
        ("Project-URL", "repository, github.com/some/repo"),
        ("Project-URL", "changelog, github.com/some/repo/blob/master/CHANGELOG.rst"),
        ("Requires-Python", ">=3.8"),
        ("Requires-Dist", "dependency1"),
        ("Requires-Dist", "dependency2>1.0.0"),
        ("Requires-Dist", "dependency3[extra]"),
        ("Requires-Dist", 'dependency4; os_name != "nt"'),
        ("Requires-Dist", 'dependency5[other-extra]>1.0; os_name == "nt"'),
        ("Provides-Extra", "test"),
        ("Requires-Dist", 'test_dependency; extra == "test"'),
        ("Requires-Dist", 'test_dependency[test_extra]; extra == "test"'),
        (
            "Requires-Dist",
            'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"',
        ),
        ("Description-Content-Type", "text/markdown"),
    ]
    assert core_metadata.get_payload() == "some readme 👋\n"


def test_rfc822_empty_import_name() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {"project": {"name": "test", "version": "0.1.0", "import-names": []}}
    )
    assert metadata.import_names == []
    assert metadata.import_namespaces is None

    core_metadata = metadata.as_rfc822()
    assert core_metadata.items() == [
        ("Metadata-Version", "2.5"),
        ("Name", "test"),
        ("Version", "0.1.0"),
        ("Import-Name", ""),
    ]


def test_import_names_private_trailing_whitespace() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "test",
                "version": "0.1.0",
                "import-names": ["name; private "],
            }
        }
    )
    assert metadata.import_names == ["name; private "]


def test_rfc822_full_import_name() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "test",
                "version": "0.1.0",
                "import-names": ["one", "two"],
                "import-namespaces": ["three"],
            }
        }
    )
    assert metadata.import_names == ["one", "two"]
    assert metadata.import_namespaces == ["three"]

    core_metadata = metadata.as_rfc822()
    assert core_metadata.items() == [
        ("Metadata-Version", "2.5"),
        ("Name", "test"),
        ("Version", "0.1.0"),
        ("Import-Name", "one"),
        ("Import-Name", "two"),
        ("Import-Namespace", "three"),
    ]


def test_as_json_spdx(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/spdx")

    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))

    core_metadata = metadata.as_json()
    assert core_metadata == {
        "license_expression": "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)",
        "license_file": [
            "AUTHORS.txt",
            "LICENSE.md",
            "LICENSE.txt",
            "licenses/LICENSE.MIT",
        ],
        "metadata_version": "2.4",
        "name": "example",
        "version": "1.2.3",
    }


def test_as_rfc822_spdx(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/spdx")

    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
    core_metadata = metadata.as_rfc822()
    assert core_metadata.items() == [
        ("Metadata-Version", "2.4"),
        ("Name", "example"),
        ("Version", "1.2.3"),
        ("License-Expression", "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)"),
        ("License-File", "AUTHORS.txt"),
        ("License-File", "LICENSE.md"),
        ("License-File", "LICENSE.txt"),
        ("License-File", "licenses/LICENSE.MIT"),
    ]

    assert core_metadata.get_payload() is None


def test_as_rfc822_spdx_empty_glob(
    monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, all_errors: bool
) -> None:
    shutil.copytree(DIR / "packages/spdx", tmp_path / "spdx")
    monkeypatch.chdir(tmp_path / "spdx")

    pathlib.Path("AUTHORS.txt").unlink()
    msg = "Every pattern in \"project.license-files\" must match at least one file: 'AUTHORS*' did not match any"

    with open("pyproject.toml", "rb") as f:
        if all_errors:
            with pytest.raises(
                pyproject_metadata.errors.ExceptionGroup,
            ) as execinfo:
                pyproject_metadata.StandardMetadata.from_pyproject(
                    tomllib.load(f), all_errors=all_errors
                )
            assert "Failed to parse pyproject.toml" in str(execinfo.value)
            assert [msg] == [str(e) for e in execinfo.value.exceptions]
        else:
            with pytest.raises(
                pyproject_metadata.ConfigurationError,
                match=re.escape(msg),
            ):
                pyproject_metadata.StandardMetadata.from_pyproject(
                    tomllib.load(f), all_errors=all_errors
                )


def test_license_file_24(
    metadata_version: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.chdir(DIR / "packages/fulltext_license")
    pre_spdx = (
        metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS
    )
    ctx = (
        contextlib.nullcontext()
        if pre_spdx
        else pytest.warns(pyproject_metadata.errors.ConfigurationWarning)
    )
    with ctx:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "fulltext_license",
                    "version": "0.1.0",
                    "license": {"file": "LICENSE.txt"},
                },
            },
            metadata_version=metadata_version,
        )
    message = str(metadata.as_rfc822())
    if metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS:
        assert "License-File: LICENSE.txt" not in message
    else:
        assert "License-File: LICENSE.txt" in message

    bmessage = bytes(metadata.as_rfc822())
    if metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS:
        assert b"License-File: LICENSE.txt" not in bmessage
    else:
        assert b"License-File: LICENSE.txt" in bmessage


def test_license_file_broken(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/broken_license")

    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "broken_license",
                "version": "0.1.0",
                "license": {"file": "LICENSE"},
            },
        },
    )
    message = str(metadata.as_rfc822())
    assert "License-File: LICENSE" not in message
    bmessage = bytes(metadata.as_rfc822())
    assert b"License-File: LICENSE" not in bmessage


def test_as_rfc822_dynamic(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.chdir(DIR / "packages/dynamic-description")

    with open("pyproject.toml", "rb") as f:
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
    metadata.dynamic_metadata = ["description"]
    core_metadata = metadata.as_rfc822()
    assert core_metadata.items() == [
        ("Metadata-Version", "2.2"),
        ("Name", "dynamic-description"),
        ("Version", "1.0.0"),
        ("Dynamic", "description"),
    ]


def test_as_rfc822_set_metadata(metadata_version: str) -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "hi",
                "version": "1.2",
                "optional-dependencies": {
                    "under_score": ["some_package"],
                    "da-sh": ["some-package"],
                    "do.t": ["some.package"],
                    "empty": [],
                },
            }
        },
        metadata_version=metadata_version,
    )
    assert metadata.metadata_version == metadata_version

    rfc822 = bytes(metadata.as_rfc822()).decode("utf-8")

    assert f"Metadata-Version: {metadata_version}" in rfc822

    assert "Provides-Extra: under-score" in rfc822
    assert "Provides-Extra: da-sh" in rfc822
    assert "Provides-Extra: do-t" in rfc822
    assert "Provides-Extra: empty" in rfc822
    assert 'Requires-Dist: some_package; extra == "under-score"' in rfc822
    assert 'Requires-Dist: some-package; extra == "da-sh"' in rfc822
    assert 'Requires-Dist: some.package; extra == "do-t"' in rfc822


def test_as_json_set_metadata() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "hi",
                "version": "1.2",
                "optional-dependencies": {
                    "under_score": ["some_package"],
                    "da-sh": ["some-package"],
                    "do.t": ["some.package"],
                    "empty": [],
                },
            }
        },
        metadata_version="2.1",
    )
    assert metadata.metadata_version == "2.1"

    json = metadata.as_json()

    assert json == {
        "metadata_version": "2.1",
        "name": "hi",
        "provides_extra": ["under-score", "da-sh", "do-t", "empty"],
        "requires_dist": [
            'some_package; extra == "under-score"',
            'some-package; extra == "da-sh"',
            'some.package; extra == "do-t"',
        ],
        "version": "1.2",
    }


def test_as_rfc822_set_metadata_invalid() -> None:
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match="The metadata_version must be one of",
    ) as err:
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "hi",
                    "version": "1.2",
                },
            },
            metadata_version="2.0",
        )
    assert "2.1" in str(err.value)
    assert "2.2" in str(err.value)
    assert "2.3" in str(err.value)


def test_as_rfc822_invalid_dynamic() -> None:
    metadata = pyproject_metadata.StandardMetadata(
        name="something",
        version=packaging.version.Version("1.0.0"),
        dynamic_metadata=["name"],
    )
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match="Metadata field 'name' cannot be declared dynamic",
    ):
        metadata.as_rfc822()
    metadata.dynamic_metadata = ["Version"]
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match="Metadata field 'Version' cannot be declared dynamic",
    ):
        metadata.as_rfc822()
    metadata.dynamic_metadata = ["unknown"]
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match="Unknown metadata field 'unknown' cannot be declared dynamic",
    ):
        metadata.as_rfc822()


def test_license_file_not_found_with_braces() -> None:
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape("License file not found ('LICENSE{oops}')"),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.0",
                    "license": {"file": "LICENSE{oops}"},
                }
            }
        )


def test_as_rfc822_invalid_dynamic_with_braces() -> None:
    metadata = pyproject_metadata.StandardMetadata(
        name="something",
        version=packaging.version.Version("1.0.0"),
        dynamic_metadata=["{key}"],
    )
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape("Unknown metadata field '{key}' cannot be declared dynamic"),
    ):
        metadata.as_rfc822()


def test_as_rfc822_mapped_dynamic() -> None:
    metadata = pyproject_metadata.StandardMetadata(
        name="something",
        version=packaging.version.Version("1.0.0"),
        dynamic_metadata=list(pyproject_metadata.field_to_metadata("description")),
    )
    assert (
        str(metadata.as_rfc822())
        == "Metadata-Version: 2.2\nName: something\nVersion: 1.0.0\nDynamic: Summary\n\n"
    )


def test_project_to_metadata_fields_are_known() -> None:
    """All metadata field names in PROJECT_TO_METADATA must be known metadata fields."""
    unknown = {
        field
        for fields in pyproject_metadata.constants.PROJECT_TO_METADATA.values()
        for field in fields
        if field.lower() not in pyproject_metadata.constants.KNOWN_METADATA_FIELDS
    }
    assert not unknown, f"Unknown metadata fields in PROJECT_TO_METADATA: {unknown}"


def test_as_rfc822_missing_version() -> None:
    metadata = pyproject_metadata.StandardMetadata(name="something")
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape('Field "project.version" missing'),
    ):
        metadata.as_rfc822()


def test_statically_defined_dynamic_field() -> None:
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape(
            'Field "project.version" declared as dynamic in "project.dynamic" but is defined'
        ),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.2.3",
                    "dynamic": [
                        "version",
                    ],
                },
            }
        )


def test_dual_defined_dynamic_field() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                "dependencies": ["example"],
                "dynamic": ["dependencies"],
            },
        }
    )
    assert len(metadata.dependencies) == 1
    assert metadata.dynamic == ["dependencies"]
    # Without dynamic metadata the field is treated statically, so the version
    # stays at the baseline rather than being bumped to 2.6.
    assert metadata.auto_metadata_version == "2.1"


@pytest.mark.parametrize("version", ["2.1", "2.2", "2.5"])
def test_dual_defined_dynamic_field_no_metadata_keeps_version(version: str) -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                "dependencies": ["example"],
                "dynamic": ["dependencies"],
            },
        },
        metadata_version=version,
    )
    assert metadata.auto_metadata_version == version
    assert len(metadata.dependencies) == 1


@pytest.mark.parametrize(
    ("field", "static_value", "header"),
    [
        ("authors", [{"name": "Author"}], "Author"),
        ("classifiers", ["Development Status :: 3 - Alpha"], "Classifier"),
        ("dependencies", ["some-dep"], "Requires-Dist"),
        ("import-names", ["foo"], "Import-Name"),
        ("import-namespaces", ["foo"], "Import-Namespace"),
        ("keywords", ["example"], "Keywords"),
        ("maintainers", [{"name": "Maintainer"}], "Maintainer"),
        ("optional-dependencies", {"test": ["pytest"]}, "Provides-Extra"),
        ("urls", {"Homepage": "https://example.com"}, "Project-URL"),
    ],
)
def test_dual_defined_dynamic_field_auto_version(
    field: str, static_value: object, header: str
) -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                field: static_value,
                "dynamic": [field],
            },
        },
        dynamic_metadata=[header],
    )
    assert metadata.auto_metadata_version == "2.6"


@pytest.mark.parametrize(
    ("field", "static_value"),
    [
        ("entry-points", {"console_scripts": {"foo": "bar:baz"}}),
        ("gui-scripts", {"foo": "bar:baz"}),
        ("scripts", {"foo": "bar:baz"}),
    ],
)
def test_dual_dynamic_field_without_metadata_header(
    field: str, static_value: object
) -> None:
    # scripts, gui-scripts and entry-points have no METADATA representation, so
    # marking them dynamic never requires 2.6, even when unrelated Dynamic
    # metadata is present.
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                field: static_value,
                "dynamic": [field],
            },
        },
        metadata_version="2.2",
        dynamic_metadata=["Requires-Dist"],
    )
    assert metadata.auto_metadata_version == "2.2"


def test_dual_dynamic_unrelated_metadata_header_no_bump() -> None:
    # The dual field (authors) is unrelated to the Dynamic header (Requires-Dist),
    # so 2.6 is not required; the version only reflects the 2.2 Dynamic header.
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                "authors": [{"name": "Author"}],
                "dynamic": ["authors"],
            },
        },
        dynamic_metadata=["Requires-Dist"],
    )
    assert metadata.auto_metadata_version == "2.2"


def test_dual_dynamic_unrelated_metadata_header_no_error() -> None:
    # An unrelated Dynamic header must not trigger the 2.6 requirement error.
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                "authors": [{"name": "Author"}],
                "dynamic": ["authors"],
            },
        },
        metadata_version="2.2",
        dynamic_metadata=["Requires-Dist"],
    )
    assert metadata.auto_metadata_version == "2.2"


@pytest.mark.parametrize("version", ["2.1", "2.2", "2.3", "2.4", "2.5"])
def test_dual_defined_dynamic_field_requires_26(version: str) -> None:
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape(
            "Fields dependencies are declared as both static and dynamic, which requires metadata_version >= 2.6"
        ),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.2.3",
                    "dependencies": ["example"],
                    "dynamic": ["dependencies"],
                },
            },
            metadata_version=version,
            dynamic_metadata=["Requires-Dist"],
        )


def test_dual_defined_dynamic_field_with_26() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                "dependencies": ["example"],
                "dynamic": ["dependencies"],
            },
        },
        metadata_version="2.6",
        dynamic_metadata=["Requires-Dist"],
    )
    assert metadata.auto_metadata_version == "2.6"
    assert len(metadata.dependencies) == 1


def test_dual_defined_license_files_auto_version(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.chdir(DIR / "packages/fulltext_license")
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                "license-files": ["LICENSE.txt"],
                "dynamic": ["license-files"],
            },
        },
        dynamic_metadata=["License-File"],
    )
    assert metadata.auto_metadata_version == "2.6"


def test_non_dual_field_still_errors() -> None:
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape(
            'Field "project.version" declared as dynamic in "project.dynamic" but is defined'
        ),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.2.3",
                    "dynamic": ["version"],
                },
            },
            metadata_version="2.6",
        )


def test_description_dual_field_still_errors() -> None:
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape(
            'Field "project.description" declared as dynamic in "project.dynamic" but is defined'
        ),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.2.3",
                    "description": "A description",
                    "dynamic": ["description"],
                },
            },
            metadata_version="2.6",
        )


@pytest.mark.parametrize(
    ("field", "static_value"),
    [
        ("version", "1.2.3"),
        ("description", "A description"),
        ("requires-python", ">=3.8"),
    ],
)
def test_non_dual_static_dynamic_field_errors_with_26(
    field: str, static_value: object
) -> None:
    with pytest.raises(
        pyproject_metadata.ConfigurationError,
        match=re.escape('declared as dynamic in "project.dynamic" but is defined'),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.2.3",
                    field: static_value,
                    "dynamic": [field],
                },
            },
            metadata_version="2.6",
        )


@pytest.mark.parametrize(
    "value",
    [
        "<3.10",
        ">3.8,<3.11",
        ">3.8,<3.11,!=3.8.4",
        "~=3.10,!=3.10.3",
    ],
)
def test_requires_python(value: str) -> None:
    pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "0.1.0",
                "requires-python": value,
            },
        }
    )


def test_version_dynamic() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "dynamic": [
                    "version",
                ],
            },
        }
    )
    metadata.version = packaging.version.Version("1.2.3")


def test_version_dynamic_unset() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "dynamic": [
                    "version",
                ],
            },
        }
    )
    assert metadata.version is None
    with pytest.raises(
        pyproject_metadata.errors.ConfigurationError,
        match=re.escape('Field "project.version" missing'),
    ):
        metadata.as_rfc822()
    with pytest.raises(
        pyproject_metadata.errors.ConfigurationError,
        match=re.escape('Field "project.version" missing'),
    ):
        metadata.as_json()

    # Assigning a dynamic version before writing the metadata works as usual.
    metadata.version = packaging.version.Version("1.2.3")
    assert "Version: 1.2.3" in metadata.as_rfc822().as_string()


def test_modify_dynamic() -> None:
    metadata = pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {
                "name": "example",
                "version": "1.2.3",
                "dynamic": [
                    "requires-python",
                ],
            },
        }
    )
    metadata.requires_python = packaging.specifiers.SpecifierSet(">=3.12")
    metadata.version = packaging.version.Version("1.2.3")


def test_missing_keys_warns() -> None:
    with pytest.warns(
        pyproject_metadata.errors.ConfigurationWarning,
        match=re.escape("Extra keys present in \"project\": 'not-real-key'"),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.2.3",
                    "not-real-key": True,
                },
            }
        )


def test_missing_keys_okay() -> None:
    pyproject_metadata.StandardMetadata.from_pyproject(
        {
            "project": {"name": "example", "version": "1.2.3", "not-real-key": True},
        },
        allow_extra_keys=True,
    )


def test_extra_top_level() -> None:
    assert not pyproject_metadata.extras_top_level(
        {"project": {}, "dependency-groups": {}}
    )
    assert {"also-not-real", "not-real"} == pyproject_metadata.extras_top_level(
        {
            "not-real": {},
            "also-not-real": {},
            "project": {},
            "build-system": {},
        }
    )


def test_extra_build_system() -> None:
    assert not pyproject_metadata.extras_build_system(
        {
            "build-system": {
                "build-backend": "one",
                "requires": ["two"],
                "backend-path": "local",
            },
        }
    )
    assert {"also-not-real", "not-real"} == pyproject_metadata.extras_build_system(
        {
            "build-system": {
                "not-real": {},
                "also-not-real": {},
            }
        }
    )


def test_multiline_description_warns() -> None:
    with pytest.warns(
        pyproject_metadata.errors.ConfigurationWarning,
        match=re.escape(
            'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.'
        ),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            {
                "project": {
                    "name": "example",
                    "version": "1.2.3",
                    "description": "this\nis multiline",
                },
            }
        )


@pytest.mark.parametrize("extra", ["my extra", "extra!", "-leading", "trailing_"])
def test_invalid_extra_name_warns(extra: str) -> None:
    data = f"""
        [project]
        name = "example"
        version = "1.2.3"

        [project.optional-dependencies]
        "{extra}" = []
    """
    with pytest.warns(
        pyproject_metadata.errors.ConfigurationWarning,
        match=re.escape(
            f'Invalid extra name {extra!r} in "project.optional-dependencies".'
        ),
    ):
        pyproject_metadata.StandardMetadata.from_pyproject(
            tomllib.loads(textwrap.dedent(data))
        )


@pytest.mark.parametrize("extra", ["test", "My-Extra", "a.b_c"])
def test_valid_extra_name_no_warning(extra: str) -> None:
    data = f"""
        [project]
        name = "example"
        version = "1.2.3"

        [project.optional-dependencies]
        "{extra}" = []
    """
    pyproject_metadata.StandardMetadata.from_pyproject(
        tomllib.loads(textwrap.dedent(data))
    )


def test_invalid_extra_name_warns_once_and_still_emitted() -> None:
    data = """
        [project]
        name = "example"
        version = "1.2.3"

        [project.optional-dependencies]
        "my extra" = []
    """
    with pytest.warns(
        pyproject_metadata.errors.ConfigurationWarning,
        match=re.escape("Invalid extra name 'my extra'"),
    ):
        metadata = pyproject_metadata.StandardMetadata.from_pyproject(
            tomllib.loads(textwrap.dedent(data))
        )

    # Emitting metadata must not warn again (filterwarnings=error would raise),
    # and the extra is not dropped.
    assert "Provides-Extra: my extra" in str(metadata.as_rfc822())
