CoolFace
Apppublic

Aluode/PerceptionLabPortable

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
metadata.py863 linesDownload Raw Back to packaging
1from __future__ import annotations2 3import email.feedparser4import email.header5import email.message6import email.parser7import email.policy8import pathlib9import sys10import typing11from typing import (12    Any,13    Callable,14    Generic,15    Literal,16    TypedDict,17    cast,18)19 20from . import licenses, requirements, specifiers, utils21from . import version as version_module22from .licenses import NormalizedLicenseExpression23 24T = typing.TypeVar("T")25 26 27if sys.version_info >= (3, 11):  # pragma: no cover28    ExceptionGroup = ExceptionGroup29else:  # pragma: no cover30 31    class ExceptionGroup(Exception):32        """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.33 34        If :external:exc:`ExceptionGroup` is already defined by Python itself,35        that version is used instead.36        """37 38        message: str39        exceptions: list[Exception]40 41        def __init__(self, message: str, exceptions: list[Exception]) -> None:42            self.message = message43            self.exceptions = exceptions44 45        def __repr__(self) -> str:46            return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"47 48 49class InvalidMetadata(ValueError):50    """A metadata field contains invalid data."""51 52    field: str53    """The name of the field that contains invalid data."""54 55    def __init__(self, field: str, message: str) -> None:56        self.field = field57        super().__init__(message)58 59 60# The RawMetadata class attempts to make as few assumptions about the underlying61# serialization formats as possible. The idea is that as long as a serialization62# formats offer some very basic primitives in *some* way then we can support63# serializing to and from that format.64class RawMetadata(TypedDict, total=False):65    """A dictionary of raw core metadata.66 67    Each field in core metadata maps to a key of this dictionary (when data is68    provided). The key is lower-case and underscores are used instead of dashes69    compared to the equivalent core metadata field. Any core metadata field that70    can be specified multiple times or can hold multiple values in a single71    field have a key with a plural name. See :class:`Metadata` whose attributes72    match the keys of this dictionary.73 74    Core metadata fields that can be specified multiple times are stored as a75    list or dict depending on which is appropriate for the field. Any fields76    which hold multiple values in a single field are stored as a list.77 78    """79 80    # Metadata 1.0 - PEP 24181    metadata_version: str82    name: str83    version: str84    platforms: list[str]85    summary: str86    description: str87    keywords: list[str]88    home_page: str89    author: str90    author_email: str91    license: str92 93    # Metadata 1.1 - PEP 31494    supported_platforms: list[str]95    download_url: str96    classifiers: list[str]97    requires: list[str]98    provides: list[str]99    obsoletes: list[str]100 101    # Metadata 1.2 - PEP 345102    maintainer: str103    maintainer_email: str104    requires_dist: list[str]105    provides_dist: list[str]106    obsoletes_dist: list[str]107    requires_python: str108    requires_external: list[str]109    project_urls: dict[str, str]110 111    # Metadata 2.0112    # PEP 426 attempted to completely revamp the metadata format113    # but got stuck without ever being able to build consensus on114    # it and ultimately ended up withdrawn.115    #116    # However, a number of tools had started emitting METADATA with117    # `2.0` Metadata-Version, so for historical reasons, this version118    # was skipped.119 120    # Metadata 2.1 - PEP 566121    description_content_type: str122    provides_extra: list[str]123 124    # Metadata 2.2 - PEP 643125    dynamic: list[str]126 127    # Metadata 2.3 - PEP 685128    # No new fields were added in PEP 685, just some edge case were129    # tightened up to provide better interoptability.130 131    # Metadata 2.4 - PEP 639132    license_expression: str133    license_files: list[str]134 135 136_STRING_FIELDS = {137    "author",138    "author_email",139    "description",140    "description_content_type",141    "download_url",142    "home_page",143    "license",144    "license_expression",145    "maintainer",146    "maintainer_email",147    "metadata_version",148    "name",149    "requires_python",150    "summary",151    "version",152}153 154_LIST_FIELDS = {155    "classifiers",156    "dynamic",157    "license_files",158    "obsoletes",159    "obsoletes_dist",160    "platforms",161    "provides",162    "provides_dist",163    "provides_extra",164    "requires",165    "requires_dist",166    "requires_external",167    "supported_platforms",168}169 170_DICT_FIELDS = {171    "project_urls",172}173 174 175def _parse_keywords(data: str) -> list[str]:176    """Split a string of comma-separated keywords into a list of keywords."""177    return [k.strip() for k in data.split(",")]178 179 180def _parse_project_urls(data: list[str]) -> dict[str, str]:181    """Parse a list of label/URL string pairings separated by a comma."""182    urls = {}183    for pair in data:184        # Our logic is slightly tricky here as we want to try and do185        # *something* reasonable with malformed data.186        #187        # The main thing that we have to worry about, is data that does188        # not have a ',' at all to split the label from the Value. There189        # isn't a singular right answer here, and we will fail validation190        # later on (if the caller is validating) so it doesn't *really*191        # matter, but since the missing value has to be an empty str192        # and our return value is dict[str, str], if we let the key193        # be the missing value, then they'd have multiple '' values that194        # overwrite each other in a accumulating dict.195        #196        # The other potentional issue is that it's possible to have the197        # same label multiple times in the metadata, with no solid "right"198        # answer with what to do in that case. As such, we'll do the only199        # thing we can, which is treat the field as unparseable and add it200        # to our list of unparsed fields.201        parts = [p.strip() for p in pair.split(",", 1)]202        parts.extend([""] * (max(0, 2 - len(parts))))  # Ensure 2 items203 204        # TODO: The spec doesn't say anything about if the keys should be205        #       considered case sensitive or not... logically they should206        #       be case-preserving and case-insensitive, but doing that207        #       would open up more cases where we might have duplicate208        #       entries.209        label, url = parts210        if label in urls:211            # The label already exists in our set of urls, so this field212            # is unparseable, and we can just add the whole thing to our213            # unparseable data and stop processing it.214            raise KeyError("duplicate labels in project urls")215        urls[label] = url216 217    return urls218 219 220def _get_payload(msg: email.message.Message, source: bytes | str) -> str:221    """Get the body of the message."""222    # If our source is a str, then our caller has managed encodings for us,223    # and we don't need to deal with it.224    if isinstance(source, str):225        payload = msg.get_payload()226        assert isinstance(payload, str)227        return payload228    # If our source is a bytes, then we're managing the encoding and we need229    # to deal with it.230    else:231        bpayload = msg.get_payload(decode=True)232        assert isinstance(bpayload, bytes)233        try:234            return bpayload.decode("utf8", "strict")235        except UnicodeDecodeError as exc:236            raise ValueError("payload in an invalid encoding") from exc237 238 239# The various parse_FORMAT functions here are intended to be as lenient as240# possible in their parsing, while still returning a correctly typed241# RawMetadata.242#243# To aid in this, we also generally want to do as little touching of the244# data as possible, except where there are possibly some historic holdovers245# that make valid data awkward to work with.246#247# While this is a lower level, intermediate format than our ``Metadata``248# class, some light touch ups can make a massive difference in usability.249 250# Map METADATA fields to RawMetadata.251_EMAIL_TO_RAW_MAPPING = {252    "author": "author",253    "author-email": "author_email",254    "classifier": "classifiers",255    "description": "description",256    "description-content-type": "description_content_type",257    "download-url": "download_url",258    "dynamic": "dynamic",259    "home-page": "home_page",260    "keywords": "keywords",261    "license": "license",262    "license-expression": "license_expression",263    "license-file": "license_files",264    "maintainer": "maintainer",265    "maintainer-email": "maintainer_email",266    "metadata-version": "metadata_version",267    "name": "name",268    "obsoletes": "obsoletes",269    "obsoletes-dist": "obsoletes_dist",270    "platform": "platforms",271    "project-url": "project_urls",272    "provides": "provides",273    "provides-dist": "provides_dist",274    "provides-extra": "provides_extra",275    "requires": "requires",276    "requires-dist": "requires_dist",277    "requires-external": "requires_external",278    "requires-python": "requires_python",279    "summary": "summary",280    "supported-platform": "supported_platforms",281    "version": "version",282}283_RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()}284 285 286def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:287    """Parse a distribution's metadata stored as email headers (e.g. from ``METADATA``).288 289    This function returns a two-item tuple of dicts. The first dict is of290    recognized fields from the core metadata specification. Fields that can be291    parsed and translated into Python's built-in types are converted292    appropriately. All other fields are left as-is. Fields that are allowed to293    appear multiple times are stored as lists.294 295    The second dict contains all other fields from the metadata. This includes296    any unrecognized fields. It also includes any fields which are expected to297    be parsed into a built-in type but were not formatted appropriately. Finally,298    any fields that are expected to appear only once but are repeated are299    included in this dict.300 301    """302    raw: dict[str, str | list[str] | dict[str, str]] = {}303    unparsed: dict[str, list[str]] = {}304 305    if isinstance(data, str):306        parsed = email.parser.Parser(policy=email.policy.compat32).parsestr(data)307    else:308        parsed = email.parser.BytesParser(policy=email.policy.compat32).parsebytes(data)309 310    # We have to wrap parsed.keys() in a set, because in the case of multiple311    # values for a key (a list), the key will appear multiple times in the312    # list of keys, but we're avoiding that by using get_all().313    for name in frozenset(parsed.keys()):314        # Header names in RFC are case insensitive, so we'll normalize to all315        # lower case to make comparisons easier.316        name = name.lower()317 318        # We use get_all() here, even for fields that aren't multiple use,319        # because otherwise someone could have e.g. two Name fields, and we320        # would just silently ignore it rather than doing something about it.321        headers = parsed.get_all(name) or []322 323        # The way the email module works when parsing bytes is that it324        # unconditionally decodes the bytes as ascii using the surrogateescape325        # handler. When you pull that data back out (such as with get_all() ),326        # it looks to see if the str has any surrogate escapes, and if it does327        # it wraps it in a Header object instead of returning the string.328        #329        # As such, we'll look for those Header objects, and fix up the encoding.330        value = []331        # Flag if we have run into any issues processing the headers, thus332        # signalling that the data belongs in 'unparsed'.333        valid_encoding = True334        for h in headers:335            # It's unclear if this can return more types than just a Header or336            # a str, so we'll just assert here to make sure.337            assert isinstance(h, (email.header.Header, str))338 339            # If it's a header object, we need to do our little dance to get340            # the real data out of it. In cases where there is invalid data341            # we're going to end up with mojibake, but there's no obvious, good342            # way around that without reimplementing parts of the Header object343            # ourselves.344            #345            # That should be fine since, if mojibacked happens, this key is346            # going into the unparsed dict anyways.347            if isinstance(h, email.header.Header):348                # The Header object stores it's data as chunks, and each chunk349                # can be independently encoded, so we'll need to check each350                # of them.351                chunks: list[tuple[bytes, str | None]] = []352                for bin, encoding in email.header.decode_header(h):353                    try:354                        bin.decode("utf8", "strict")355                    except UnicodeDecodeError:356                        # Enable mojibake.357                        encoding = "latin1"358                        valid_encoding = False359                    else:360                        encoding = "utf8"361                    chunks.append((bin, encoding))362 363                # Turn our chunks back into a Header object, then let that364                # Header object do the right thing to turn them into a365                # string for us.366                value.append(str(email.header.make_header(chunks)))367            # This is already a string, so just add it.368            else:369                value.append(h)370 371        # We've processed all of our values to get them into a list of str,372        # but we may have mojibake data, in which case this is an unparsed373        # field.374        if not valid_encoding:375            unparsed[name] = value376            continue377 378        raw_name = _EMAIL_TO_RAW_MAPPING.get(name)379        if raw_name is None:380            # This is a bit of a weird situation, we've encountered a key that381            # we don't know what it means, so we don't know whether it's meant382            # to be a list or not.383            #384            # Since we can't really tell one way or another, we'll just leave it385            # as a list, even though it may be a single item list, because that's386            # what makes the most sense for email headers.387            unparsed[name] = value388            continue389 390        # If this is one of our string fields, then we'll check to see if our391        # value is a list of a single item. If it is then we'll assume that392        # it was emitted as a single string, and unwrap the str from inside393        # the list.394        #395        # If it's any other kind of data, then we haven't the faintest clue396        # what we should parse it as, and we have to just add it to our list397        # of unparsed stuff.398        if raw_name in _STRING_FIELDS and len(value) == 1:399            raw[raw_name] = value[0]400        # If this is one of our list of string fields, then we can just assign401        # the value, since email *only* has strings, and our get_all() call402        # above ensures that this is a list.403        elif raw_name in _LIST_FIELDS:404            raw[raw_name] = value405        # Special Case: Keywords406        # The keywords field is implemented in the metadata spec as a str,407        # but it conceptually is a list of strings, and is serialized using408        # ", ".join(keywords), so we'll do some light data massaging to turn409        # this into what it logically is.410        elif raw_name == "keywords" and len(value) == 1:411            raw[raw_name] = _parse_keywords(value[0])412        # Special Case: Project-URL413        # The project urls is implemented in the metadata spec as a list of414        # specially-formatted strings that represent a key and a value, which415        # is fundamentally a mapping, however the email format doesn't support416        # mappings in a sane way, so it was crammed into a list of strings417        # instead.418        #419        # We will do a little light data massaging to turn this into a map as420        # it logically should be.421        elif raw_name == "project_urls":422            try:423                raw[raw_name] = _parse_project_urls(value)424            except KeyError:425                unparsed[name] = value426        # Nothing that we've done has managed to parse this, so it'll just427        # throw it in our unparseable data and move on.428        else:429            unparsed[name] = value430 431    # We need to support getting the Description from the message payload in432    # addition to getting it from the the headers. This does mean, though, there433    # is the possibility of it being set both ways, in which case we put both434    # in 'unparsed' since we don't know which is right.435    try:436        payload = _get_payload(parsed, data)437    except ValueError:438        unparsed.setdefault("description", []).append(439            parsed.get_payload(decode=isinstance(data, bytes))  # type: ignore[call-overload]440        )441    else:442        if payload:443            # Check to see if we've already got a description, if so then both444            # it, and this body move to unparseable.445            if "description" in raw:446                description_header = cast(str, raw.pop("description"))447                unparsed.setdefault("description", []).extend(448                    [description_header, payload]449                )450            elif "description" in unparsed:451                unparsed["description"].append(payload)452            else:453                raw["description"] = payload454 455    # We need to cast our `raw` to a metadata, because a TypedDict only support456    # literal key names, but we're computing our key names on purpose, but the457    # way this function is implemented, our `TypedDict` can only have valid key458    # names.459    return cast(RawMetadata, raw), unparsed460 461 462_NOT_FOUND = object()463 464 465# Keep the two values in sync.466_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]467_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]468 469_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])470 471 472class _Validator(Generic[T]):473    """Validate a metadata field.474 475    All _process_*() methods correspond to a core metadata field. The method is476    called with the field's raw value. If the raw value is valid it is returned477    in its "enriched" form (e.g. ``version.Version`` for the ``Version`` field).478    If the raw value is invalid, :exc:`InvalidMetadata` is raised (with a cause479    as appropriate).480    """481 482    name: str483    raw_name: str484    added: _MetadataVersion485 486    def __init__(487        self,488        *,489        added: _MetadataVersion = "1.0",490    ) -> None:491        self.added = added492 493    def __set_name__(self, _owner: Metadata, name: str) -> None:494        self.name = name495        self.raw_name = _RAW_TO_EMAIL_MAPPING[name]496 497    def __get__(self, instance: Metadata, _owner: type[Metadata]) -> T:498        # With Python 3.8, the caching can be replaced with functools.cached_property().499        # No need to check the cache as attribute lookup will resolve into the500        # instance's __dict__ before __get__ is called.501        cache = instance.__dict__502        value = instance._raw.get(self.name)503 504        # To make the _process_* methods easier, we'll check if the value is None505        # and if this field is NOT a required attribute, and if both of those506        # things are true, we'll skip the the converter. This will mean that the507        # converters never have to deal with the None union.508        if self.name in _REQUIRED_ATTRS or value is not None:509            try:510                converter: Callable[[Any], T] = getattr(self, f"_process_{self.name}")511            except AttributeError:512                pass513            else:514                value = converter(value)515 516        cache[self.name] = value517        try:518            del instance._raw[self.name]  # type: ignore[misc]519        except KeyError:520            pass521 522        return cast(T, value)523 524    def _invalid_metadata(525        self, msg: str, cause: Exception | None = None526    ) -> InvalidMetadata:527        exc = InvalidMetadata(528            self.raw_name, msg.format_map({"field": repr(self.raw_name)})529        )530        exc.__cause__ = cause531        return exc532 533    def _process_metadata_version(self, value: str) -> _MetadataVersion:534        # Implicitly makes Metadata-Version required.535        if value not in _VALID_METADATA_VERSIONS:536            raise self._invalid_metadata(f"{value!r} is not a valid metadata version")537        return cast(_MetadataVersion, value)538 539    def _process_name(self, value: str) -> str:540        if not value:541            raise self._invalid_metadata("{field} is a required field")542        # Validate the name as a side-effect.543        try:544            utils.canonicalize_name(value, validate=True)545        except utils.InvalidName as exc:546            raise self._invalid_metadata(547                f"{value!r} is invalid for {{field}}", cause=exc548            ) from exc549        else:550            return value551 552    def _process_version(self, value: str) -> version_module.Version:553        if not value:554            raise self._invalid_metadata("{field} is a required field")555        try:556            return version_module.parse(value)557        except version_module.InvalidVersion as exc:558            raise self._invalid_metadata(559                f"{value!r} is invalid for {{field}}", cause=exc560            ) from exc561 562    def _process_summary(self, value: str) -> str:563        """Check the field contains no newlines."""564        if "\n" in value:565            raise self._invalid_metadata("{field} must be a single line")566        return value567 568    def _process_description_content_type(self, value: str) -> str:569        content_types = {"text/plain", "text/x-rst", "text/markdown"}570        message = email.message.EmailMessage()571        message["content-type"] = value572 573        content_type, parameters = (574            # Defaults to `text/plain` if parsing failed.575            message.get_content_type().lower(),576            message["content-type"].params,577        )578        # Check if content-type is valid or defaulted to `text/plain` and thus was579        # not parseable.580        if content_type not in content_types or content_type not in value.lower():581            raise self._invalid_metadata(582                f"{{field}} must be one of {list(content_types)}, not {value!r}"583            )584 585        charset = parameters.get("charset", "UTF-8")586        if charset != "UTF-8":587            raise self._invalid_metadata(588                f"{{field}} can only specify the UTF-8 charset, not {list(charset)}"589            )590 591        markdown_variants = {"GFM", "CommonMark"}592        variant = parameters.get("variant", "GFM")  # Use an acceptable default.593        if content_type == "text/markdown" and variant not in markdown_variants:594            raise self._invalid_metadata(595                f"valid Markdown variants for {{field}} are {list(markdown_variants)}, "596                f"not {variant!r}",597            )598        return value599 600    def _process_dynamic(self, value: list[str]) -> list[str]:601        for dynamic_field in map(str.lower, value):602            if dynamic_field in {"name", "version", "metadata-version"}:603                raise self._invalid_metadata(604                    f"{dynamic_field!r} is not allowed as a dynamic field"605                )606            elif dynamic_field not in _EMAIL_TO_RAW_MAPPING:607                raise self._invalid_metadata(608                    f"{dynamic_field!r} is not a valid dynamic field"609                )610        return list(map(str.lower, value))611 612    def _process_provides_extra(613        self,614        value: list[str],615    ) -> list[utils.NormalizedName]:616        normalized_names = []617        try:618            for name in value:619                normalized_names.append(utils.canonicalize_name(name, validate=True))620        except utils.InvalidName as exc:621            raise self._invalid_metadata(622                f"{name!r} is invalid for {{field}}", cause=exc623            ) from exc624        else:625            return normalized_names626 627    def _process_requires_python(self, value: str) -> specifiers.SpecifierSet:628        try:629            return specifiers.SpecifierSet(value)630        except specifiers.InvalidSpecifier as exc:631            raise self._invalid_metadata(632                f"{value!r} is invalid for {{field}}", cause=exc633            ) from exc634 635    def _process_requires_dist(636        self,637        value: list[str],638    ) -> list[requirements.Requirement]:639        reqs = []640        try:641            for req in value:642                reqs.append(requirements.Requirement(req))643        except requirements.InvalidRequirement as exc:644            raise self._invalid_metadata(645                f"{req!r} is invalid for {{field}}", cause=exc646            ) from exc647        else:648            return reqs649 650    def _process_license_expression(651        self, value: str652    ) -> NormalizedLicenseExpression | None:653        try:654            return licenses.canonicalize_license_expression(value)655        except ValueError as exc:656            raise self._invalid_metadata(657                f"{value!r} is invalid for {{field}}", cause=exc658            ) from exc659 660    def _process_license_files(self, value: list[str]) -> list[str]:661        paths = []662        for path in value:663            if ".." in path:664                raise self._invalid_metadata(665                    f"{path!r} is invalid for {{field}}, "666                    "parent directory indicators are not allowed"667                )668            if "*" in path:669                raise self._invalid_metadata(670                    f"{path!r} is invalid for {{field}}, paths must be resolved"671                )672            if (673                pathlib.PurePosixPath(path).is_absolute()674                or pathlib.PureWindowsPath(path).is_absolute()675            ):676                raise self._invalid_metadata(677                    f"{path!r} is invalid for {{field}}, paths must be relative"678                )679            if pathlib.PureWindowsPath(path).as_posix() != path:680                raise self._invalid_metadata(681                    f"{path!r} is invalid for {{field}}, paths must use '/' delimiter"682                )683            paths.append(path)684        return paths685 686 687class Metadata:688    """Representation of distribution metadata.689 690    Compared to :class:`RawMetadata`, this class provides objects representing691    metadata fields instead of only using built-in types. Any invalid metadata692    will cause :exc:`InvalidMetadata` to be raised (with a693    :py:attr:`~BaseException.__cause__` attribute as appropriate).694    """695 696    _raw: RawMetadata697 698    @classmethod699    def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata:700        """Create an instance from :class:`RawMetadata`.701 702        If *validate* is true, all metadata will be validated. All exceptions703        related to validation will be gathered and raised as an :class:`ExceptionGroup`.704        """705        ins = cls()706        ins._raw = data.copy()  # Mutations occur due to caching enriched values.707 708        if validate:709            exceptions: list[Exception] = []710            try:711                metadata_version = ins.metadata_version712                metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version)713            except InvalidMetadata as metadata_version_exc:714                exceptions.append(metadata_version_exc)715                metadata_version = None716 717            # Make sure to check for the fields that are present, the required718            # fields (so their absence can be reported).719            fields_to_check = frozenset(ins._raw) | _REQUIRED_ATTRS720            # Remove fields that have already been checked.721            fields_to_check -= {"metadata_version"}722 723            for key in fields_to_check:724                try:725                    if metadata_version:726                        # Can't use getattr() as that triggers descriptor protocol which727                        # will fail due to no value for the instance argument.728                        try:729                            field_metadata_version = cls.__dict__[key].added730                        except KeyError:731                            exc = InvalidMetadata(key, f"unrecognized field: {key!r}")732                            exceptions.append(exc)733                            continue734                        field_age = _VALID_METADATA_VERSIONS.index(735                            field_metadata_version736                        )737                        if field_age > metadata_age:738                            field = _RAW_TO_EMAIL_MAPPING[key]739                            exc = InvalidMetadata(740                                field,741                                f"{field} introduced in metadata version "742                                f"{field_metadata_version}, not {metadata_version}",743                            )744                            exceptions.append(exc)745                            continue746                    getattr(ins, key)747                except InvalidMetadata as exc:748                    exceptions.append(exc)749 750            if exceptions:751                raise ExceptionGroup("invalid metadata", exceptions)752 753        return ins754 755    @classmethod756    def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:757        """Parse metadata from email headers.758 759        If *validate* is true, the metadata will be validated. All exceptions760        related to validation will be gathered and raised as an :class:`ExceptionGroup`.761        """762        raw, unparsed = parse_email(data)763 764        if validate:765            exceptions: list[Exception] = []766            for unparsed_key in unparsed:767                if unparsed_key in _EMAIL_TO_RAW_MAPPING:768                    message = f"{unparsed_key!r} has invalid data"769                else:770                    message = f"unrecognized field: {unparsed_key!r}"771                exceptions.append(InvalidMetadata(unparsed_key, message))772 773            if exceptions:774                raise ExceptionGroup("unparsed", exceptions)775 776        try:777            return cls.from_raw(raw, validate=validate)778        except ExceptionGroup as exc_group:779            raise ExceptionGroup(780                "invalid or unparsed metadata", exc_group.exceptions781            ) from None782 783    metadata_version: _Validator[_MetadataVersion] = _Validator()784    """:external:ref:`core-metadata-metadata-version`785    (required; validated to be a valid metadata version)"""786    # `name` is not normalized/typed to NormalizedName so as to provide access to787    # the original/raw name.788    name: _Validator[str] = _Validator()789    """:external:ref:`core-metadata-name`790    (required; validated using :func:`~packaging.utils.canonicalize_name` and its791    *validate* parameter)"""792    version: _Validator[version_module.Version] = _Validator()793    """:external:ref:`core-metadata-version` (required)"""794    dynamic: _Validator[list[str] | None] = _Validator(795        added="2.2",796    )797    """:external:ref:`core-metadata-dynamic`798    (validated against core metadata field names and lowercased)"""799    platforms: _Validator[list[str] | None] = _Validator()800    """:external:ref:`core-metadata-platform`"""801    supported_platforms: _Validator[list[str] | None] = _Validator(added="1.1")802    """:external:ref:`core-metadata-supported-platform`"""803    summary: _Validator[str | None] = _Validator()804    """:external:ref:`core-metadata-summary` (validated to contain no newlines)"""805    description: _Validator[str | None] = _Validator()  # TODO 2.1: can be in body806    """:external:ref:`core-metadata-description`"""807    description_content_type: _Validator[str | None] = _Validator(added="2.1")808    """:external:ref:`core-metadata-description-content-type` (validated)"""809    keywords: _Validator[list[str] | None] = _Validator()810    """:external:ref:`core-metadata-keywords`"""811    home_page: _Validator[str | None] = _Validator()812    """:external:ref:`core-metadata-home-page`"""813    download_url: _Validator[str | None] = _Validator(added="1.1")814    """:external:ref:`core-metadata-download-url`"""815    author: _Validator[str | None] = _Validator()816    """:external:ref:`core-metadata-author`"""817    author_email: _Validator[str | None] = _Validator()818    """:external:ref:`core-metadata-author-email`"""819    maintainer: _Validator[str | None] = _Validator(added="1.2")820    """:external:ref:`core-metadata-maintainer`"""821    maintainer_email: _Validator[str | None] = _Validator(added="1.2")822    """:external:ref:`core-metadata-maintainer-email`"""823    license: _Validator[str | None] = _Validator()824    """:external:ref:`core-metadata-license`"""825    license_expression: _Validator[NormalizedLicenseExpression | None] = _Validator(826        added="2.4"827    )828    """:external:ref:`core-metadata-license-expression`"""829    license_files: _Validator[list[str] | None] = _Validator(added="2.4")830    """:external:ref:`core-metadata-license-file`"""831    classifiers: _Validator[list[str] | None] = _Validator(added="1.1")832    """:external:ref:`core-metadata-classifier`"""833    requires_dist: _Validator[list[requirements.Requirement] | None] = _Validator(834        added="1.2"835    )836    """:external:ref:`core-metadata-requires-dist`"""837    requires_python: _Validator[specifiers.SpecifierSet | None] = _Validator(838        added="1.2"839    )840    """:external:ref:`core-metadata-requires-python`"""841    # Because `Requires-External` allows for non-PEP 440 version specifiers, we842    # don't do any processing on the values.843    requires_external: _Validator[list[str] | None] = _Validator(added="1.2")844    """:external:ref:`core-metadata-requires-external`"""845    project_urls: _Validator[dict[str, str] | None] = _Validator(added="1.2")846    """:external:ref:`core-metadata-project-url`"""847    # PEP 685 lets us raise an error if an extra doesn't pass `Name` validation848    # regardless of metadata version.849    provides_extra: _Validator[list[utils.NormalizedName] | None] = _Validator(850        added="2.1",851    )852    """:external:ref:`core-metadata-provides-extra`"""853    provides_dist: _Validator[list[str] | None] = _Validator(added="1.2")854    """:external:ref:`core-metadata-provides-dist`"""855    obsoletes_dist: _Validator[list[str] | None] = _Validator(added="1.2")856    """:external:ref:`core-metadata-obsoletes-dist`"""857    requires: _Validator[list[str] | None] = _Validator(added="1.1")858    """``Requires`` (deprecated)"""859    provides: _Validator[list[str] | None] = _Validator(added="1.1")860    """``Provides`` (deprecated)"""861    obsoletes: _Validator[list[str] | None] = _Validator(added="1.1")862    """``Obsoletes`` (deprecated)"""863 
Aluode/PerceptionLabPortable · CoolFace