Source code for pyproject_metadata
# 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__
[docs]
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])
[docs]
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
[docs]
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
)
[docs]
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
[docs]
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
[docs]
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)
[docs]
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())
[docs]
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")
[docs]
@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)
[docs]
@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
[docs]
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
[docs]
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
[docs]
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