Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pyproject-metadata #847

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
441 changes: 441 additions & 0 deletions src/packaging/_pyproject.py

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions src/packaging/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from __future__ import annotations

import contextlib
import dataclasses
import sys
from collections.abc import Generator
from typing import Any

__all__ = ["ExceptionGroup", "ConfigurationError", "ConfigurationWarning"]


if sys.version_info >= (3, 11): # pragma: no cover
from builtins import ExceptionGroup
else: # pragma: no cover

class ExceptionGroup(Exception):
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.

If :external:exc:`ExceptionGroup` is already defined by Python itself,
that version is used instead.
"""

message: str
exceptions: list[Exception]

def __init__(self, message: str, exceptions: list[Exception]) -> None:
self.message = message
self.exceptions = exceptions

def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"


class ConfigurationError(Exception):
"""Error in the backend metadata. Has an optional key attribute, which will be non-None
if the error is related to a single key in the pyproject.toml file."""

def __init__(self, msg: str, *, key: str | None = None):
super().__init__(msg)
self._key = key

@property
def key(self) -> str | None: # pragma: no cover
return self._key


class ConfigurationWarning(UserWarning):
"""Warnings about backend metadata."""


@dataclasses.dataclass
class ErrorCollector:
"""
Collect errors and raise them as a group at the end (if collect_errors is True),
otherwise raise them immediately.
"""

errors: list[Exception] = dataclasses.field(default_factory=list)

def config_error(
self,
msg: str,
*,
key: str | None = None,
got: Any = None,
got_type: type[Any] | None = None,
**kwargs: Any,
) -> None:
"""Raise a configuration error, or add it to the error list."""
msg = msg.format(key=f'"{key}"', **kwargs)
if got is not None:
msg = f"{msg} (got {got!r})"
if got_type is not None:
msg = f"{msg} (got {got_type.__name__})"

self.errors.append(ConfigurationError(msg, key=key))

def finalize(self, msg: str) -> None:
"""Raise a group exception if there are any errors."""
if self.errors:
raise ExceptionGroup(msg, self.errors)

@contextlib.contextmanager
def collect(self) -> Generator[None, None, None]:
"""Support nesting; add any grouped errors to the error list."""
try:
yield
except ExceptionGroup as error:
self.errors.extend(error.exceptions)
94 changes: 71 additions & 23 deletions src/packaging/metadata.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import builtins
import email.feedparser
import email.header
import email.message
Expand All @@ -19,33 +18,12 @@

from . import licenses, requirements, specifiers, utils
from . import version as version_module
from .errors import ExceptionGroup
from .licenses import NormalizedLicenseExpression

T = typing.TypeVar("T")


if "ExceptionGroup" in builtins.__dict__: # pragma: no cover
ExceptionGroup = ExceptionGroup
else: # pragma: no cover

class ExceptionGroup(Exception):
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.

If :external:exc:`ExceptionGroup` is already defined by Python itself,
that version is used instead.
"""

message: str
exceptions: list[Exception]

def __init__(self, message: str, exceptions: list[Exception]) -> None:
self.message = message
self.exceptions = exceptions

def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"


class InvalidMetadata(ValueError):
"""A metadata field contains invalid data."""

Expand Down Expand Up @@ -170,6 +148,7 @@ class RawMetadata(TypedDict, total=False):
_DICT_FIELDS = {
"project_urls",
}
ALL_FIELDS = _STRING_FIELDS | _LIST_FIELDS | _DICT_FIELDS


def _parse_keywords(data: str) -> list[str]:
Expand Down Expand Up @@ -281,6 +260,43 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
_RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()}


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

utf8 = True
mangle_from_ = False
max_line_length = 0

def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
size = len(name) + 2
value = value.replace("\n", "\n" + " " * size)
return (name, value)


# This class is for writing RFC822 messages
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:
super().__init__(policy=RFC822Policy())

def as_bytes(
self, unixfrom: bool = False, policy: email.policy.Policy | None = None
) -> bytes:
"""
This handles unicode encoding.
"""
return self.as_string(unixfrom, policy=policy).encode("utf-8")


def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
"""Parse a distribution's metadata stored as email headers (e.g. from ``METADATA``).

Expand Down Expand Up @@ -859,3 +875,35 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:
"""``Provides`` (deprecated)"""
obsoletes: _Validator[list[str] | None] = _Validator(added="1.1")
"""``Obsoletes`` (deprecated)"""

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

def _write_metadata(self, message: RFC822Message) -> None:
"""
Return an RFC822 message with the metadata.
"""
for name, validator in self.__class__.__dict__.items():
if isinstance(validator, _Validator) and name != "description":
value = getattr(self, name)
email_name = _RAW_TO_EMAIL_MAPPING[name]
if value is not None:
if email_name == "project-url":
for label, url in value.items():
message[email_name] = f"{label}, {url}"
elif email_name == "keywords":
message[email_name] = ",".join(value)
elif isinstance(value, list):
for item in value:
message[email_name] = str(item)
else:
message[email_name] = str(value)

# The description is a special case because it is in the body of the message.
if self.description is not None:
message.set_payload(self.description)
Loading
Loading