Aluode/PerceptionLabPortable
0
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 