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