From 67fb709820a7e319e379b9b20df6ffb68bae582f Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 10 Oct 2024 03:04:01 -0400 Subject: [PATCH] fix: update to pyproject_metadata 0.9.0b5 (#263) * fix: update to pyproject_metadata 0.9.0b5 Signed-off-by: Henry Schreiner * chore: bump to 0.9.0b7 Signed-off-by: Henry Schreiner --------- Signed-off-by: Henry Schreiner --- pyproject.toml | 4 +- scripts/patches/pyproject_metadata.patch | 131 +- .../_vendor/pyproject_metadata/__init__.py | 1060 +++++++---------- .../_vendor/pyproject_metadata/constants.py | 103 ++ .../_vendor/pyproject_metadata/errors.py | 119 ++ .../pyproject_metadata/project_table.py | 118 ++ .../_vendor/pyproject_metadata/pyproject.py | 451 +++++++ src/pdm/backend/_vendor/vendor.txt | 2 +- 8 files changed, 1266 insertions(+), 722 deletions(-) create mode 100644 src/pdm/backend/_vendor/pyproject_metadata/constants.py create mode 100644 src/pdm/backend/_vendor/pyproject_metadata/errors.py create mode 100644 src/pdm/backend/_vendor/pyproject_metadata/project_table.py create mode 100644 src/pdm/backend/_vendor/pyproject_metadata/pyproject.py diff --git a/pyproject.toml b/pyproject.toml index ee75219..e0ea2d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,9 @@ patches-dir = "scripts/patches" protected-files = ["__init__.py", "README.md", "vendor.txt"] [tool.vendoring.transformations] -substitute = [] +substitute = [ + {match = "import packaging", replace = "import pdm.backend._vendor.packaging"}, +] drop = [ "bin/", "*.so", diff --git a/scripts/patches/pyproject_metadata.patch b/scripts/patches/pyproject_metadata.patch index b2891bf..cb8f25c 100644 --- a/scripts/patches/pyproject_metadata.patch +++ b/scripts/patches/pyproject_metadata.patch @@ -1,113 +1,24 @@ diff --git a/src/pdm/backend/_vendor/pyproject_metadata/__init__.py b/src/pdm/backend/_vendor/pyproject_metadata/__init__.py -index 70c452b..6a3af49 100644 +index 39ae6e4..283264b 100644 --- a/src/pdm/backend/_vendor/pyproject_metadata/__init__.py +++ b/src/pdm/backend/_vendor/pyproject_metadata/__init__.py -@@ -20,18 +20,18 @@ if typing.TYPE_CHECKING: - from collections.abc import Generator, Iterable, Mapping - from typing import Any - -- from packaging.requirements import Requirement -+ from pdm.backend._vendor.packaging.requirements import Requirement - - if sys.version_info < (3, 11): - from typing_extensions import Self - else: - from typing import Self - --import packaging.markers --import packaging.requirements --import packaging.specifiers --import packaging.utils --import packaging.version -+import pdm.backend._vendor.packaging.markers as pkg_markers -+import pdm.backend._vendor.packaging.requirements as pkg_requirements -+import pdm.backend._vendor.packaging.specifiers as pkg_specifiers -+import pdm.backend._vendor.packaging.utils as pkg_utils -+import pdm.backend._vendor.packaging.version as pkg_version - - - __version__ = '0.9.0b4' -@@ -397,8 +397,8 @@ class ProjectFetcher(DataFetcher): - requirements: list[Requirement] = [] - for req in requirement_strings: - try: -- requirements.append(packaging.requirements.Requirement(req)) -- except packaging.requirements.InvalidRequirement as e: -+ requirements.append(pkg_requirements.Requirement(req)) -+ except pkg_requirements.InvalidRequirement as e: - msg = ( - 'Field "project.dependencies" contains an invalid PEP 508 ' - f'requirement string "{req}" ("{e}")' -@@ -439,9 +439,9 @@ class ProjectFetcher(DataFetcher): - raise ConfigurationError(msg) - try: - requirements_dict[extra].append( -- packaging.requirements.Requirement(req) -+ pkg_requirements.Requirement(req) - ) -- except packaging.requirements.InvalidRequirement as e: -+ except pkg_requirements.InvalidRequirement as e: - msg = ( - f'Field "project.optional-dependencies.{extra}" contains ' - f'an invalid PEP 508 requirement string "{req}" ("{e}")' -@@ -501,12 +501,12 @@ class Readme: - @dataclasses.dataclass - class StandardMetadata: - name: str -- version: packaging.version.Version | None = None -+ version: pkg_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 -+ requires_python: pkg_specifiers.SpecifierSet | None = None - dependencies: list[Requirement] = dataclasses.field(default_factory=list) - optional_dependencies: dict[str, list[Requirement]] = dataclasses.field( - default_factory=dict -@@ -617,7 +617,7 @@ class StandardMetadata: - - @property - def canonical_name(self) -> str: -- return packaging.utils.canonicalize_name(self.name) -+ return pkg_utils.canonicalize_name(self.name) - - @classmethod - def from_pyproject( -@@ -661,7 +661,7 @@ class StandardMetadata: - - version_string = fetcher.get_str('project.version') - requires_python_string = fetcher.get_str('project.requires-python') -- version = packaging.version.Version(version_string) if version_string else None -+ version = pkg_version.Version(version_string) if version_string else None - - if version is None and 'version' not in dynamic: - msg = 'Field "project.version" missing and "version" not specified in "project.dynamic"' -@@ -673,7 +673,7 @@ class StandardMetadata: - description = fetcher.get_str('project.description') - - requires_python = ( -- packaging.specifiers.SpecifierSet(requires_python_string) -+ pkg_specifiers.SpecifierSet(requires_python_string) - if requires_python_string - else None - ) -@@ -791,15 +791,15 @@ class StandardMetadata: - requirement = copy.copy(requirement) - if requirement.marker: - if 'or' in requirement.marker._markers: -- requirement.marker = packaging.markers.Marker( -+ requirement.marker = pkg_markers.Marker( - f'({requirement.marker}) and extra == "{extra}"' - ) - else: -- requirement.marker = packaging.markers.Marker( -+ requirement.marker = pkg_markers.Marker( - f'{requirement.marker} and extra == "{extra}"' - ) - else: -- requirement.marker = packaging.markers.Marker(f'extra == "{extra}"') -+ requirement.marker = pkg_markers.Marker(f'extra == "{extra}"') - return requirement - - +@@ -63,6 +63,7 @@ if typing.TYPE_CHECKING: + + from .project_table import Dynamic, PyProjectTable + ++import pdm.backend._vendor.packaging as packaging + import packaging.markers + import packaging.specifiers + import packaging.utils +diff --git a/src/pdm/backend/_vendor/pyproject_metadata/pyproject.py b/src/pdm/backend/_vendor/pyproject_metadata/pyproject.py +index d1822e1..a85f9a1 100644 +--- a/src/pdm/backend/_vendor/pyproject_metadata/pyproject.py ++++ b/src/pdm/backend/_vendor/pyproject_metadata/pyproject.py +@@ -13,6 +13,7 @@ import pathlib + import re + import typing + ++import pdm.backend._vendor.packaging as packaging + import packaging.requirements + + from .errors import ErrorCollector diff --git a/src/pdm/backend/_vendor/pyproject_metadata/__init__.py b/src/pdm/backend/_vendor/pyproject_metadata/__init__.py index 6a3af49..22fe253 100644 --- a/src/pdm/backend/_vendor/pyproject_metadata/__init__.py +++ b/src/pdm/backend/_vendor/pyproject_metadata/__init__.py @@ -1,5 +1,35 @@ # SPDX-License-Identifier: MIT +""" +This is pyproject_metadata, a library for working with PEP 621 metadata. + +Example usage: + +.. code-block:: python + + from pdm.backend._vendor.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 = self.metadata.entrypoints.copy() + ep["console_scripts"] = self.metadata.scripts + ep["gui_scripts"] = self.metadata.gui_scripts + for group, entries in ep.items(): + if entries: + with open("entry_points.txt", "w", encoding="utf-8") as f: + 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 copy @@ -15,9 +45,13 @@ import typing import warnings +# Build backends may vendor this package, so all imports are relative. +from . import constants +from .errors import ConfigurationError, ConfigurationWarning, ErrorCollector +from .pyproject import License, PyProjectReader, Readme if typing.TYPE_CHECKING: - from collections.abc import Generator, Iterable, Mapping + from collections.abc import Mapping from typing import Any from pdm.backend._vendor.packaging.requirements import Requirement @@ -27,89 +61,27 @@ else: from typing import Self -import pdm.backend._vendor.packaging.markers as pkg_markers -import pdm.backend._vendor.packaging.requirements as pkg_requirements -import pdm.backend._vendor.packaging.specifiers as pkg_specifiers -import pdm.backend._vendor.packaging.utils as pkg_utils -import pdm.backend._vendor.packaging.version as pkg_version - - -__version__ = '0.9.0b4' - -KNOWN_METADATA_VERSIONS = {'2.1', '2.2', '2.3', '2.4'} -PRE_SPDX_METADATA_VERSIONS = {'2.1', '2.2', '2.3'} - -PROJECT_TO_METADATA = { - 'authors': frozenset(['Author', 'Author-Email']), - 'classifiers': frozenset(['Classifier']), - 'dependencies': frozenset(['Requires-Dist']), - 'description': frozenset(['Summary']), - 'dynamic': frozenset(), - 'entry-points': frozenset(), - 'gui-scripts': frozenset(), - 'keywords': frozenset(['Keywords']), - 'license': frozenset(['License', 'License-Expression']), - 'license-files': frozenset(['License-File']), - 'maintainers': frozenset(['Maintainer', 'Maintainer-Email']), - 'name': frozenset(['Name']), - 'optional-dependencies': frozenset(['Provides-Extra', 'Requires-Dist']), - 'readme': frozenset(['Description', 'Description-Content-Type']), - 'requires-python': frozenset(['Requires-Python']), - 'scripts': frozenset(), - 'urls': frozenset(['Project-URL']), - 'version': frozenset(['Version']), -} - -KNOWN_TOPLEVEL_FIELDS = {'build-system', 'project', 'tool'} -KNOWN_BUILD_SYSTEM_FIELDS = {'backend-path', 'build-backend', 'requires'} -KNOWN_PROJECT_FIELDS = set(PROJECT_TO_METADATA) - -KNOWN_METADATA_FIELDS = { - 'author', - 'author-email', - 'classifier', - 'description', - 'description-content-type', - 'download-urL', # Not specified via pyproject standards - 'dynamic', # Can't be in dynamic - 'home-page', # Not specified via pyproject standards - 'keywords', - 'license', - 'license-expression', - 'license-file', - 'maintainer', - 'maintainer-email', - 'metadata-version', - 'name', # Can't be in dynamic - 'obsoletes', # Deprecated - 'obsoletes-dist', # Rarly used - 'platform', # Not specified via pyproject standards - 'project-url', - 'provides', # Deprecated - 'provides-dist', # Rarly used - 'provides-extra', - 'requires', # Deprecated - 'requires-dist', - 'requires-external', # Not specified via pyproject standards - 'requires-python', - 'summary', - 'supported-platform', # Not specified via pyproject standards - 'version', # Can't be in dynamic -} + from .project_table import Dynamic, PyProjectTable + +import pdm.backend._vendor.packaging as packaging +import pdm.backend._vendor.packaging.markers +import pdm.backend._vendor.packaging.specifiers +import pdm.backend._vendor.packaging.utils +import pdm.backend._vendor.packaging.version +__version__ = "0.9.0b7" __all__ = [ - 'ConfigurationError', - 'ConfigurationWarning', - 'License', - 'RFC822Message', - 'RFC822Policy', - 'Readme', - 'StandardMetadata', - 'field_to_metadata', - 'validate_build_system', - 'validate_project', - 'validate_top_level', + "ConfigurationError", + "License", + "RFC822Message", + "RFC822Policy", + "Readme", + "StandardMetadata", + "field_to_metadata", + "extras_build_system", + "extras_project", + "extras_top_level", ] @@ -121,44 +93,31 @@ def field_to_metadata(field: str) -> frozenset[str]: """ Return the METADATA fields that correspond to a project field. """ - return frozenset(PROJECT_TO_METADATA[field]) - - -def validate_top_level(pyproject: Mapping[str, Any]) -> None: - extra_keys = set(pyproject) - KNOWN_TOPLEVEL_FIELDS - if extra_keys: - msg = f'Extra keys present in pyproject.toml: {extra_keys}' - raise ConfigurationError(msg) - - -def validate_build_system(pyproject: Mapping[str, Any]) -> None: - extra_keys = set(pyproject.get('build-system', [])) - KNOWN_BUILD_SYSTEM_FIELDS - if extra_keys: - msg = f'Extra keys present in "build-system": {extra_keys}' - raise ConfigurationError(msg) - + return frozenset(constants.PROJECT_TO_METADATA[field]) -def validate_project(pyproject: Mapping[str, Any]) -> None: - extra_keys = set(pyproject.get('project', [])) - KNOWN_PROJECT_FIELDS - if extra_keys: - msg = f'Extra keys present in "project": {extra_keys}' - raise ConfigurationError(msg) +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 -class ConfigurationError(Exception): - """Error in the backend metadata.""" - - 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 +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 + ) -class ConfigurationWarning(UserWarning): - """Warnings about backend metadata.""" +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 @@ -178,10 +137,43 @@ def __setitem__(self, name: str, value: str | None) -> 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 + class RFC822Policy(email.policy.EmailPolicy): """ - This is `email.policy.EmailPolicy`, but with a simple ``header_store_parse`` + This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse`` implementation that handles multiline values, and some nice defaults. """ @@ -190,17 +182,17 @@ class RFC822Policy(email.policy.EmailPolicy): max_line_length = 0 def header_store_parse(self, name: str, value: str) -> tuple[str, str]: - if name.lower() not in KNOWN_METADATA_FIELDS: - msg = f'Unknown field "{name}"' + 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) + value = value.replace("\n", "\n" + " " * size) return (name, value) class RFC822Message(email.message.EmailMessage): """ - This is `email.message.EmailMessage` with two small changes: it defaults to + 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()`. """ @@ -211,302 +203,27 @@ def __init__(self) -> None: def as_bytes( self, unixfrom: bool = False, policy: email.policy.Policy | None = None ) -> bytes: - return self.as_string(unixfrom, policy=policy).encode('utf-8') - - -class DataFetcher: - def __init__(self, data: Mapping[str, Any]) -> None: - self._data = data - - def __contains__(self, key: str) -> bool: - val = self._data - try: - for part in key.split('.'): - val = val[part] - except KeyError: - return False - return True - - def get(self, key: str) -> Any: - val = self._data - for part in key.split('.'): - val = val[part] - return val - - def get_str(self, key: str) -> str | None: - try: - val = self.get(key) - if not isinstance(val, str): - msg = f'Field "{key}" has an invalid type, expecting a string (got "{val}")' - raise ConfigurationError(msg, key=key) - return val - except KeyError: - return None - - def get_list(self, key: str) -> list[str] | None: - try: - val = self.get(key) - if not isinstance(val, list): - msg = f'Field "{key}" has an invalid type, expecting a list of strings (got "{val}")' - raise ConfigurationError(msg, key=val) - for item in val: - if not isinstance(item, str): - msg = f'Field "{key}" contains item with invalid type, expecting a string (got "{item}")' - raise ConfigurationError(msg, key=key) - return val - except KeyError: - return None - - def get_dict(self, key: str) -> dict[str, str]: - try: - val = self.get(key) - if not isinstance(val, dict): - msg = f'Field "{key}" has an invalid type, expecting a dictionary of strings (got "{val}")' - raise ConfigurationError(msg, key=key) - for subkey, item in val.items(): - if not isinstance(item, str): - msg = f'Field "{key}.{subkey}" has an invalid type, expecting a string (got "{item}")' - raise ConfigurationError(msg, key=f'{key}.{subkey}') - return val - except KeyError: - return {} - - def get_people(self, key: str) -> list[tuple[str, str | None]]: - try: - val = self.get(key) - if not ( - isinstance(val, list) - and all(isinstance(x, dict) for x in val) - and all( - isinstance(item, str) - for items in [_dict.values() for _dict in val] - for item in items - ) - ): - msg = ( - f'Field "{key}" has an invalid type, expecting a list of ' - f'dictionaries containing the "name" and/or "email" keys (got "{val}")' - ) - raise ConfigurationError(msg, key=key) - return [(entry.get('name', 'Unknown'), entry.get('email')) for entry in val] - except KeyError: - return [] - - -class ProjectFetcher(DataFetcher): - def get_license(self, project_dir: pathlib.Path) -> License | str | None: - if 'project.license' not in self: - return None - - val = self.get('project.license') - if isinstance(val, str): - return self.get_str('project.license') - - if isinstance(val, dict): - _license = self.get_dict('project.license') - else: - msg = f'Field "project.license" has an invalid type, expecting a string or dictionary of strings (got "{val}")' - raise ConfigurationError(msg) - - for field in _license: - if field not in ('file', 'text'): - msg = f'Unexpected field "project.license.{field}"' - raise ConfigurationError(msg, key=f'project.license.{field}') - - file: pathlib.Path | None = None - filename = self.get_str('project.license.file') - text = self.get_str('project.license.text') - - if (filename and text) or (not filename and not text): - msg = f'Invalid "project.license" value, expecting either "file" or "text" (got "{_license}")' - raise ConfigurationError(msg, key='project.license') - - if filename: - file = project_dir.joinpath(filename) - if not file.is_file(): - msg = f'License file not found ("{filename}")' - raise ConfigurationError(msg, key='project.license.file') - text = file.read_text(encoding='utf-8') - - assert text is not None - return License(text, file) - - def get_license_files(self, project_dir: pathlib.Path) -> list[pathlib.Path] | None: - license_files = self.get_list('project.license-files') - if license_files is None: - return None - - return list(_get_files_from_globs(project_dir, license_files)) - - def get_readme(self, project_dir: pathlib.Path) -> Readme | None: # noqa: C901, PLR0912 - if 'project.readme' not in self: - return None - - filename: str | None - file: pathlib.Path | None = None - text: str | None - content_type: str | None - - readme = self.get('project.readme') - if isinstance(readme, str): - # readme is a file - text = None - filename = readme - if filename.endswith('.md'): - content_type = 'text/markdown' - elif filename.endswith('.rst'): - content_type = 'text/x-rst' - else: - msg = f'Could not infer content type for readme file "{filename}"' - raise ConfigurationError(msg, key='project.readme') - elif isinstance(readme, dict): - # readme is a dict containing either 'file' or 'text', and content-type - for field in readme: - if field not in ('content-type', 'file', 'text'): - msg = f'Unexpected field "project.readme.{field}"' - raise ConfigurationError(msg, key=f'project.readme.{field}') - content_type = self.get_str('project.readme.content-type') - filename = self.get_str('project.readme.file') - text = self.get_str('project.readme.text') - if (filename and text) or (not filename and not text): - msg = f'Invalid "project.readme" value, expecting either "file" or "text" (got "{readme}")' - raise ConfigurationError(msg, key='project.readme') - if not content_type: - msg = 'Field "project.readme.content-type" missing' - raise ConfigurationError(msg, key='project.readme.content-type') - else: - msg = ( - f'Field "project.readme" has an invalid type, expecting either, ' - f'a string or dictionary of strings (got "{readme}")' - ) - raise ConfigurationError(msg, key='project.readme') - - if filename: - file = project_dir.joinpath(filename) - if not file.is_file(): - msg = f'Readme file not found ("{filename}")' - raise ConfigurationError(msg, key='project.readme.file') - text = file.read_text(encoding='utf-8') - - assert text is not None - return Readme(text, file, content_type) - - def get_dependencies(self) -> list[Requirement]: - requirement_strings = self.get_list('project.dependencies') or [] - - requirements: list[Requirement] = [] - for req in requirement_strings: - try: - requirements.append(pkg_requirements.Requirement(req)) - except pkg_requirements.InvalidRequirement as e: - msg = ( - 'Field "project.dependencies" contains an invalid PEP 508 ' - f'requirement string "{req}" ("{e}")' - ) - raise ConfigurationError(msg) from None - return requirements - - def get_optional_dependencies( - self, - ) -> dict[str, list[Requirement]]: - try: - val = self.get('project.optional-dependencies') - except KeyError: - return {} - - requirements_dict: dict[str, list[Requirement]] = {} - if not isinstance(val, dict): - msg = ( - 'Field "project.optional-dependencies" has an invalid type, expecting a ' - f'dictionary of PEP 508 requirement strings (got "{val}")' - ) - raise ConfigurationError(msg) - for extra, requirements in val.copy().items(): - assert isinstance(extra, str) - if not isinstance(requirements, list): - msg = ( - f'Field "project.optional-dependencies.{extra}" has an invalid type, expecting a ' - f'dictionary PEP 508 requirement strings (got "{requirements}")' - ) - raise ConfigurationError(msg) - requirements_dict[extra] = [] - for req in requirements: - if not isinstance(req, str): - msg = ( - f'Field "project.optional-dependencies.{extra}" has an invalid type, ' - f'expecting a PEP 508 requirement string (got "{req}")' - ) - raise ConfigurationError(msg) - try: - requirements_dict[extra].append( - pkg_requirements.Requirement(req) - ) - except pkg_requirements.InvalidRequirement as e: - msg = ( - f'Field "project.optional-dependencies.{extra}" contains ' - f'an invalid PEP 508 requirement string "{req}" ("{e}")' - ) - raise ConfigurationError(msg) from None - return dict(requirements_dict) - - def get_entrypoints(self) -> dict[str, dict[str, str]]: - try: - val = self.get('project.entry-points') - except KeyError: - return {} - if not isinstance(val, dict): - msg = ( - 'Field "project.entry-points" has an invalid type, expecting a ' - f'dictionary of entrypoint sections (got "{val}")' - ) - raise ConfigurationError(msg) - for section, entrypoints in val.items(): - assert isinstance(section, str) - if not re.match(r'^\w+(\.\w+)*$', section): - msg = ( - 'Field "project.entry-points" has an invalid value, expecting a name ' - f'containing only alphanumeric, underscore, or dot characters (got "{section}")' - ) - raise ConfigurationError(msg) - if not isinstance(entrypoints, dict): - msg = ( - f'Field "project.entry-points.{section}" has an invalid type, expecting a ' - f'dictionary of entrypoints (got "{entrypoints}")' - ) - raise ConfigurationError(msg) - for name, entrypoint in entrypoints.items(): - assert isinstance(name, str) - if not isinstance(entrypoint, str): - msg = ( - f'Field "project.entry-points.{section}.{name}" has an invalid type, ' - f'expecting a string (got "{entrypoint}")' - ) - raise ConfigurationError(msg) - return val - - -@dataclasses.dataclass(frozen=True) -class License: - text: str - file: pathlib.Path | None - - -@dataclasses.dataclass(frozen=True) -class Readme: - text: str - file: pathlib.Path | None - content_type: str + """ + This handles unicode encoding. + """ + return self.as_string(unixfrom, policy=policy).encode("utf-8") @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: pkg_version.Version | None = None + 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: pkg_specifiers.SpecifierSet | 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 @@ -519,300 +236,423 @@ class StandardMetadata: 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) - dynamic: list[str] = dataclasses.field(default_factory=list) + dynamic: list[Dynamic] = dataclasses.field(default_factory=list) """ This field is used to track dynamic fields. You can't set a field not in this list. """ + dynamic_metadata: list[str] = dataclasses.field(default_factory=list) """ - This is a list of METADATA fields that can change inbetween SDist and wheel. Requires metadata_version 2.2+. + 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. + """ _locked_metadata: bool = False + """ + Internal flag to prevent setting non-dynamic fields after initialization. + """ def __post_init__(self) -> None: self.validate() def __setattr__(self, name: str, value: Any) -> None: - if self._locked_metadata and name.replace('_', '-') not in set(self.dynamic) | { - 'metadata-version', - 'dynamic-metadata', - }: - msg = f'Field "{name}" is not dynamic' - raise AttributeError(msg) + if self._locked_metadata: + metadata_name = name.replace("_", "-") + locked_fields = constants.KNOWN_METADATA_FIELDS - set(self.dynamic) + if metadata_name in locked_fields: + msg = f"Field {name!r} is not dynamic" + raise AttributeError(msg) super().__setattr__(name, value) + @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 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) + + @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. + """ + pyproject = PyProjectReader(collect_errors=all_errors) + + pyproject_table: PyProjectTable = data # type: ignore[assignment] + if "project" not in pyproject_table: + msg = "Section {key} missing in pyproject.toml" + pyproject.config_error(msg, key="project") + pyproject.finalize("Failed to parse pyproject.toml") + msg = "Unreachable code" # pragma: no cover + raise AssertionError(msg) # pragma: no cover + + project = pyproject_table["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}" + pyproject.config_error( + msg, + key="project", + extra_keys=extra_keys_str, + warn=allow_extra_keys is None, + ) + + dynamic = pyproject.get_dynamic(project) + + for field in dynamic: + if field in data["project"]: + msg = 'Field {key} declared as dynamic in "project.dynamic" but is defined' + pyproject.config_error(msg, key=f"project.{field}") + + raw_name = project.get("name") + name = "UNKNOWN" + if raw_name is None: + msg = "Field {key} missing" + pyproject.config_error(msg, key="project.name") + else: + tmp_name = pyproject.ensure_str(raw_name, "project.name") + if tmp_name is not None: + name = tmp_name + + 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, "project.version") + if version_string is not None: + try: + version = ( + packaging.version.Version(version_string) + if version_string + else None + ) + except packaging.version.InvalidVersion: + msg = "Invalid {key} value, expecting a valid PEP 440 version" + pyproject.config_error( + msg, key="project.version", got=version_string + ) + elif "version" not in dynamic: + msg = ( + "Field {key} missing and 'version' not specified in \"project.dynamic\"" + ) + pyproject.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, "project.description") + 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, "project.requires-python" + ) + if requires_python_string is not None: + try: + requires_python = packaging.specifiers.SpecifierSet( + requires_python_string + ) + except packaging.specifiers.InvalidSpecifier: + msg = "Invalid {key} value, expecting a valid specifier set" + pyproject.config_error( + msg, key="project.requires-python", got=requires_python_string + ) + + self = None + with pyproject.collect(): + self = cls( + name=name, + version=version, + description=description, + license=pyproject.get_license(project, project_dir), + license_files=pyproject.get_license_files(project, project_dir), + readme=pyproject.get_readme(project, project_dir), + requires_python=requires_python, + dependencies=pyproject.get_dependencies(project), + optional_dependencies=pyproject.get_optional_dependencies(project), + entrypoints=pyproject.get_entrypoints(project), + authors=pyproject.ensure_people( + project.get("authors", []), "project.authors" + ), + maintainers=pyproject.ensure_people( + project.get("maintainers", []), "project.maintainers" + ), + urls=pyproject.ensure_dict(project.get("urls", {}), "project.urls") + or {}, + classifiers=pyproject.ensure_list( + project.get("classifiers", []), "project.classifiers" + ) + or [], + keywords=pyproject.ensure_list( + project.get("keywords", []), "project.keywords" + ) + or [], + scripts=pyproject.ensure_dict( + project.get("scripts", {}), "project.scripts" + ) + or {}, + gui_scripts=pyproject.ensure_dict( + project.get("gui-scripts", {}), "project.gui-scripts" + ) + or {}, + dynamic=dynamic, + dynamic_metadata=dynamic_metadata or [], + metadata_version=metadata_version, + all_errors=all_errors, + ) + self._locked_metadata = True + + pyproject.finalize("Failed to parse pyproject.toml") + assert self is not None + return self + + 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 + + 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 + def validate(self, *, warn: bool = True) -> None: # noqa: C901 - if self.auto_metadata_version not in KNOWN_METADATA_VERSIONS: - msg = f'The metadata_version must be one of {KNOWN_METADATA_VERSIONS} or None (default)' - raise ConfigurationError(msg) + """ + 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) + - ``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 + """ + 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) # See https://packaging.python.org/en/latest/specifications/core-metadata/#name and # https://packaging.python.org/en/latest/specifications/name-normalization/#name-format if not re.match( - r'^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$', self.name, re.IGNORECASE + r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", self.name, re.IGNORECASE ): msg = ( - f'Invalid project name "{self.name}". A valid name consists only of ASCII letters and ' - 'numbers, period, underscore and hyphen. It must start and end with a letter or number' + "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" ) - raise ConfigurationError(msg) + errors.config_error(msg, key="project.name", name=self.name) if self.license_files is not None and isinstance(self.license, License): - msg = '"project.license-files" must not be used when "project.license" is not a SPDX license expression' - raise ConfigurationError(msg) + 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 + c.startswith("License ::") for c in self.classifiers ): - msg = 'Setting "project.license" to an SPDX license expression is not compatible with "License ::" classifiers' - raise ConfigurationError(msg) + 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: + 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 PRE_SPDX_METADATA_VERSIONS: + 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): + 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', + "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead", ConfigurationWarning, stacklevel=2, ) if ( isinstance(self.license, str) - and self.auto_metadata_version in PRE_SPDX_METADATA_VERSIONS + and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS ): - msg = 'Setting "project.license" to an SPDX license expression is supported only when emitting metadata version >= 2.4' - raise ConfigurationError(msg) + msg = "Setting {key} to an SPDX license expression is supported only 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 PRE_SPDX_METADATA_VERSIONS + and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS ): - msg = '"project.license-files" is supported only when emitting metadata version >= 2.4' - raise ConfigurationError(msg) - - @property - def auto_metadata_version(self) -> str: - if self.metadata_version is not None: - return self.metadata_version - - 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 pkg_utils.canonicalize_name(self.name) - - @classmethod - def from_pyproject( - 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, - ) -> Self: - fetcher = ProjectFetcher(data) - project_dir = pathlib.Path(project_dir) - - if 'project' not in fetcher: - msg = 'Section "project" missing in pyproject.toml' - raise ConfigurationError(msg) + msg = "{key} is supported only when emitting metadata version >= 2.4" + errors.config_error(msg, key="project.license-files") - if allow_extra_keys is None: - try: - validate_project(data) - except ConfigurationError as err: - warnings.warn(str(err), ConfigurationWarning, stacklevel=2) - elif not allow_extra_keys: - validate_project(data) - - dynamic = fetcher.get_list('project.dynamic') or [] - if 'name' in dynamic: - msg = 'Unsupported field "name" in "project.dynamic"' - raise ConfigurationError(msg) + errors.finalize("Metadata validation failed") - for field in dynamic: - if field in data['project']: - msg = f'Field "project.{field}" declared as dynamic in "project.dynamic" but is defined' - raise ConfigurationError(msg) - - name = fetcher.get_str('project.name') - if not name: - msg = 'Field "project.name" missing' - raise ConfigurationError(msg) - - version_string = fetcher.get_str('project.version') - requires_python_string = fetcher.get_str('project.requires-python') - version = pkg_version.Version(version_string) if version_string else None - - if version is None and 'version' not in dynamic: - msg = 'Field "project.version" missing and "version" not specified in "project.dynamic"' - raise ConfigurationError(msg) - - # Description fills Summary, which cannot be multiline - # However, throwing an error isn't backward compatible, - # so leave it up to the users for now. - description = fetcher.get_str('project.description') - - requires_python = ( - pkg_specifiers.SpecifierSet(requires_python_string) - if requires_python_string - else None - ) - - self = cls( - name=name, - version=version, - description=description, - license=fetcher.get_license(project_dir), - license_files=fetcher.get_license_files(project_dir), - readme=fetcher.get_readme(project_dir), - requires_python=requires_python, - dependencies=fetcher.get_dependencies(), - optional_dependencies=fetcher.get_optional_dependencies(), - entrypoints=fetcher.get_entrypoints(), - authors=fetcher.get_people('project.authors'), - maintainers=fetcher.get_people('project.maintainers'), - urls=fetcher.get_dict('project.urls'), - classifiers=fetcher.get_list('project.classifiers') or [], - keywords=fetcher.get_list('project.keywords') or [], - scripts=fetcher.get_dict('project.scripts'), - gui_scripts=fetcher.get_dict('project.gui-scripts'), - dynamic=dynamic, - dynamic_metadata=dynamic_metadata or [], - metadata_version=metadata_version, - ) - self._locked_metadata = True - return self - - def as_rfc822(self) -> RFC822Message: - message = RFC822Message() - self.write_to_rfc822(message) - return message - - def write_to_rfc822(self, message: email.message.Message) -> None: # noqa: C901, PLR0912 + def _write_metadata( # noqa: C901 + self, smart_message: _SmartMessageSetter | _JSonMessageSetter + ) -> None: + """ + Write the metadata to the message. Handles JSON or Message. + """ self.validate(warn=False) - smart_message = _SmartMessageSetter(message) - - smart_message['Metadata-Version'] = self.auto_metadata_version - smart_message['Name'] = self.name + smart_message["Metadata-Version"] = self.auto_metadata_version + smart_message["Name"] = self.name if not self.version: - msg = 'Missing version field' + msg = "Missing version field" raise ConfigurationError(msg) - smart_message['Version'] = str(self.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) - if 'homepage' in self.urls: - smart_message['Home-page'] = self.urls['homepage'] + smart_message["Summary"] = self.description + smart_message["Keywords"] = ",".join(self.keywords) or None + if "homepage" in self.urls: + smart_message["Home-page"] = self.urls["homepage"] # skip 'Download-URL' - smart_message['Author'] = self._name_list(self.authors) - smart_message['Author-Email'] = self._email_list(self.authors) - smart_message['Maintainer'] = self._name_list(self.maintainers) - smart_message['Maintainer-Email'] = self._email_list(self.maintainers) + 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 + smart_message["License"] = self.license.text elif isinstance(self.license, str): - smart_message['License-Expression'] = self.license + 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'] = os.fspath(license_file.as_posix()) + smart_message["License-File"] = os.fspath(license_file.as_posix()) for classifier in self.classifiers: - smart_message['Classifier'] = classifier + 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.capitalize()}, {url}' + smart_message["Project-URL"] = f"{name.capitalize()}, {url}" if self.requires_python: - smart_message['Requires-Python'] = str(self.requires_python) + smart_message["Requires-Python"] = str(self.requires_python) for dep in self.dependencies: - smart_message['Requires-Dist'] = str(dep) + 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 + norm_extra = extra.replace(".", "-").replace("_", "-").lower() + smart_message["Provides-Extra"] = norm_extra for requirement in requirements: - smart_message['Requires-Dist'] = str( - self._build_extra_req(norm_extra, requirement) + smart_message["Requires-Dist"] = str( + _build_extra_req(norm_extra, requirement) ) if self.readme: if self.readme.content_type: - smart_message['Description-Content-Type'] = self.readme.content_type - message.set_payload(self.readme.text) + smart_message["Description-Content-Type"] = self.readme.content_type + smart_message.set_payload(self.readme.text) # Core Metadata 2.2 - if self.auto_metadata_version != '2.1': + if self.auto_metadata_version != "2.1": for field in self.dynamic_metadata: - if field.lower() in {'name', 'version', 'dynamic'}: - msg = f'Field cannot be set as dynamic metadata: {field}' + if field.lower() in {"name", "version", "dynamic"}: + msg = f"Field cannot be set as dynamic metadata: {field}" raise ConfigurationError(msg) - if field.lower() not in KNOWN_METADATA_FIELDS: - msg = f'Field is not known: {field}' + if field.lower() not in constants.KNOWN_METADATA_FIELDS: + msg = f"Field is not known: {field}" raise ConfigurationError(msg) - smart_message['Dynamic'] = field + smart_message["Dynamic"] = field - def _name_list(self, people: list[tuple[str, str | None]]) -> str: - return ', '.join(name for name, email_ in people if not email_) - def _email_list(self, people: list[tuple[str, str | None]]) -> str: - return ', '.join( +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 + + +def _email_list(people: list[tuple[str, str | None]]) -> str | None: + """ + Build a comma-separated list of emails. + """ + return ( + ", ".join( email.utils.formataddr((name, _email)) for name, _email in people if _email ) - - def _build_extra_req( - self, - extra: str, - requirement: Requirement, - ) -> Requirement: - # append or add our extra marker - requirement = copy.copy(requirement) - if requirement.marker: - if 'or' in requirement.marker._markers: - requirement.marker = pkg_markers.Marker( - f'({requirement.marker}) and extra == "{extra}"' - ) - else: - requirement.marker = pkg_markers.Marker( - f'{requirement.marker} and extra == "{extra}"' - ) - else: - requirement.marker = pkg_markers.Marker(f'extra == "{extra}"') - return requirement + or None + ) -def _get_files_from_globs( - project_dir: pathlib.Path, globs: Iterable[str] -) -> Generator[pathlib.Path, None, None]: - for glob in globs: - if glob.startswith(('..', '/')): - msg = f'"{glob}" is an invalid "project.license-files" glob: the pattern must match files within the project directory' - raise ConfigurationError(msg) - files = [f for f in project_dir.glob(glob) if f.is_file()] - if not files: - msg = f'Every pattern in "project.license-files" must match at least one file: "{glob}" did not match any' - raise ConfigurationError(msg) - for f in files: - yield f.relative_to(project_dir) +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: + 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 diff --git a/src/pdm/backend/_vendor/pyproject_metadata/constants.py b/src/pdm/backend/_vendor/pyproject_metadata/constants.py new file mode 100644 index 0000000..afa1185 --- /dev/null +++ b/src/pdm/backend/_vendor/pyproject_metadata/constants.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MIT + +""" +Constants for the pyproject_metadata package, collected here to make them easy +to update. These should be considered mostly private. +""" + +from __future__ import annotations + +__all__ = [ + "KNOWN_BUILD_SYSTEM_FIELDS", + "KNOWN_METADATA_FIELDS", + "KNOWN_METADATA_VERSIONS", + "KNOWN_METADATA_VERSIONS", + "KNOWN_MULTIUSE", + "KNOWN_PROJECT_FIELDS", + "KNOWN_TOPLEVEL_FIELDS", + "PRE_SPDX_METADATA_VERSIONS", + "PROJECT_TO_METADATA", +] + + +def __dir__() -> list[str]: + return __all__ + + +KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} +PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} + +PROJECT_TO_METADATA = { + "authors": frozenset(["Author", "Author-Email"]), + "classifiers": frozenset(["Classifier"]), + "dependencies": frozenset(["Requires-Dist"]), + "description": frozenset(["Summary"]), + "dynamic": frozenset(), + "entry-points": frozenset(), + "gui-scripts": frozenset(), + "keywords": frozenset(["Keywords"]), + "license": frozenset(["License", "License-Expression"]), + "license-files": frozenset(["License-File"]), + "maintainers": frozenset(["Maintainer", "Maintainer-Email"]), + "name": frozenset(["Name"]), + "optional-dependencies": frozenset(["Provides-Extra", "Requires-Dist"]), + "readme": frozenset(["Description", "Description-Content-Type"]), + "requires-python": frozenset(["Requires-Python"]), + "scripts": frozenset(), + "urls": frozenset(["Project-URL"]), + "version": frozenset(["Version"]), +} + +KNOWN_TOPLEVEL_FIELDS = {"build-system", "project", "tool"} +KNOWN_BUILD_SYSTEM_FIELDS = {"backend-path", "build-backend", "requires"} +KNOWN_PROJECT_FIELDS = set(PROJECT_TO_METADATA) + +KNOWN_METADATA_FIELDS = { + "author", + "author-email", + "classifier", + "description", + "description-content-type", + "download-url", # Not specified via pyproject standards + "dynamic", # Can't be in dynamic + "home-page", # Not specified via pyproject standards + "keywords", + "license", + "license-expression", + "license-file", + "maintainer", + "maintainer-email", + "metadata-version", + "name", # Can't be in dynamic + "obsoletes", # Deprecated + "obsoletes-dist", # Rarely used + "platform", # Not specified via pyproject standards + "project-url", + "provides", # Deprecated + "provides-dist", # Rarely used + "provides-extra", + "requires", # Deprecated + "requires-dist", + "requires-external", # Not specified via pyproject standards + "requires-python", + "summary", + "supported-platform", # Not specified via pyproject standards + "version", # Can't be in dynamic +} + +KNOWN_MULTIUSE = { + "dynamic", + "platform", + "provides-extra", + "supported-platform", + "license-file", + "classifier", + "requires-dist", + "requires-external", + "project-url", + "provides-dist", + "obsoletes-dist", + "requires", # Deprecated + "obsoletes", # Deprecated + "provides", # Deprecated +} diff --git a/src/pdm/backend/_vendor/pyproject_metadata/errors.py b/src/pdm/backend/_vendor/pyproject_metadata/errors.py new file mode 100644 index 0000000..4fe90c2 --- /dev/null +++ b/src/pdm/backend/_vendor/pyproject_metadata/errors.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: MIT + +""" +This module defines exceptions and error handling utilities. It is the +recommend path to access ``ConfiguratonError``, ``ConfigurationWarning``, and +``ExceptionGroup``. For backward compatibility, ``ConfigurationError`` is +re-exported in the top-level package. +""" + +from __future__ import annotations + +import builtins +import contextlib +import dataclasses +import sys +import typing +import warnings + +__all__ = [ + "ConfigurationError", + "ConfigurationWarning", + "ExceptionGroup", +] + + +def __dir__() -> list[str]: + return __all__ + + +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.""" + + +if sys.version_info >= (3, 11): + ExceptionGroup = builtins.ExceptionGroup +else: + + class ExceptionGroup(Exception): + """A minimal implementation of `ExceptionGroup` from Python 3.11. + + Users can replace this with a more complete implementation, such as from + the exceptiongroup backport package, if better error messages and + integration with tooling is desired and the addition of a dependency is + acceptable. + """ + + 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})" + + +@dataclasses.dataclass +class ErrorCollector: + """ + Collect errors and raise them as a group at the end (if collect_errors is True), + otherwise raise them immediately. + """ + + collect_errors: bool + errors: list[Exception] = dataclasses.field(default_factory=list) + + def config_error( + self, + msg: str, + *, + key: str | None = None, + got: typing.Any = None, + got_type: type[typing.Any] | None = None, + warn: bool = False, + **kwargs: typing.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__})" + + if warn: + warnings.warn(msg, ConfigurationWarning, stacklevel=3) + elif self.collect_errors: + self.errors.append(ConfigurationError(msg, key=key)) + else: + raise 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) -> typing.Generator[None, None, None]: + """Support nesting; add any grouped errors to the error list.""" + if self.collect_errors: + try: + yield + except ExceptionGroup as error: + self.errors.extend(error.exceptions) + else: + yield diff --git a/src/pdm/backend/_vendor/pyproject_metadata/project_table.py b/src/pdm/backend/_vendor/pyproject_metadata/project_table.py new file mode 100644 index 0000000..84d21df --- /dev/null +++ b/src/pdm/backend/_vendor/pyproject_metadata/project_table.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: MIT + +""" +This module contains type definitions for the tables used in the +``pyproject.toml``. You should either import this at type-check time only, or +make sure ``typing_extensions`` is available for Python 3.10 and below. + +Documentation notice: the fields with hyphens are not shown due to a sphinx-autodoc bug. +""" + +from __future__ import annotations + +import sys +from typing import Any, Dict, List, Union + +if sys.version_info < (3, 11): + from typing_extensions import Required +else: + from typing import Required + +if sys.version_info < (3, 8): + from typing_extensions import Literal, TypedDict +else: + from typing import Literal, TypedDict + + +__all__ = [ + "BuildSystemTable", + "ContactTable", + "Dynamic", + "LicenseTable", + "ProjectTable", + "PyProjectTable", + "ReadmeTable", +] + + +def __dir__() -> list[str]: + return __all__ + + +class ContactTable(TypedDict, total=False): + name: str + email: str + + +class LicenseTable(TypedDict, total=False): + text: str + file: str + + +ReadmeTable = TypedDict( + "ReadmeTable", {"file": str, "text": str, "content-type": str}, total=False +) + +Dynamic = Literal[ + "authors", + "classifiers", + "dependencies", + "description", + "dynamic", + "entry-points", + "gui-scripts", + "keywords", + "license", + "maintainers", + "optional-dependencies", + "readme", + "requires-python", + "scripts", + "urls", + "version", +] + +ProjectTable = TypedDict( + "ProjectTable", + { + "name": Required[str], + "version": str, + "description": str, + "license": Union[LicenseTable, str], + "license-files": List[str], + "readme": Union[str, ReadmeTable], + "requires-python": str, + "dependencies": List[str], + "optional-dependencies": Dict[str, List[str]], + "entry-points": Dict[str, Dict[str, str]], + "authors": List[ContactTable], + "maintainers": List[ContactTable], + "urls": Dict[str, str], + "classifiers": List[str], + "keywords": List[str], + "scripts": Dict[str, str], + "gui-scripts": Dict[str, str], + "dynamic": List[Dynamic], + }, + total=False, +) + +BuildSystemTable = TypedDict( + "BuildSystemTable", + { + "build-backend": str, + "requires": List[str], + "backend-path": List[str], + }, + total=False, +) + +PyProjectTable = TypedDict( + "PyProjectTable", + { + "build-system": BuildSystemTable, + "project": ProjectTable, + "tool": Dict[str, Any], + }, + total=False, +) diff --git a/src/pdm/backend/_vendor/pyproject_metadata/pyproject.py b/src/pdm/backend/_vendor/pyproject_metadata/pyproject.py new file mode 100644 index 0000000..45ef9aa --- /dev/null +++ b/src/pdm/backend/_vendor/pyproject_metadata/pyproject.py @@ -0,0 +1,451 @@ +# SPDX-License-Identifier: MIT + +""" +This module focues on reading pyproject.toml fields with error collection. It is +mostly internal, except for License and Readme classes, which are re-exported in +the top-level package. +""" + +from __future__ import annotations + +import dataclasses +import pathlib +import re +import typing + +import pdm.backend._vendor.packaging as packaging +import pdm.backend._vendor.packaging.requirements + +from .errors import ErrorCollector + +if typing.TYPE_CHECKING: + from collections.abc import Generator, Iterable, Sequence + + from pdm.backend._vendor.packaging.requirements import Requirement + + from .project_table import ContactTable, Dynamic, ProjectTable + + +__all__ = [ + "License", + "Readme", +] + + +def __dir__() -> list[str]: + return __all__ + + +@dataclasses.dataclass(frozen=True) +class License: + """ + This represents a classic license, which contains text, and optionally a + file path. Modern licenses are just SPDX identifiers, which are strings. + """ + + text: str + file: pathlib.Path | None + + +@dataclasses.dataclass(frozen=True) +class Readme: + """ + This represents a readme, which contains text and a content type, and + optionally a file path. + """ + + text: str + file: pathlib.Path | None + content_type: str + + +T = typing.TypeVar("T") + + +@dataclasses.dataclass +class PyProjectReader(ErrorCollector): + """Class for reading pyproject.toml fields with error collection. + + Unrelated errors are collected and raised at once if the `collect_errors` + parameter is set to `True`. Some methods will return None if an error was + raised. Most of them expect a non-None value as input to enforce the caller + to handle missing vs. error correctly. The exact design is based on usage, + as this is an internal class. + """ + + def ensure_str(self, value: str, key: str) -> str | None: + """Ensure that a value is a string.""" + if isinstance(value, str): + return value + + msg = "Field {key} has an invalid type, expecting a string" + self.config_error(msg, key=key, got_type=type(value)) + return None + + def ensure_list(self, val: list[T], key: str) -> list[T] | None: + """Ensure that a value is a list of strings.""" + if not isinstance(val, list): + msg = "Field {key} has an invalid type, expecting a list of strings" + self.config_error(msg, key=key, got_type=type(val)) + return None + for item in val: + if not isinstance(item, str): + msg = "Field {key} contains item with invalid type, expecting a string" + self.config_error(msg, key=key, got_type=type(item)) + return None + + return val + + def ensure_dict(self, val: dict[str, str], key: str) -> dict[str, str] | None: + """Ensure that a value is a dictionary of strings.""" + if not isinstance(val, dict): + msg = "Field {key} has an invalid type, expecting a table of strings" + self.config_error(msg, key=key, got_type=type(val)) + return None + for subkey, item in val.items(): + if not isinstance(item, str): + msg = "Field {key} has an invalid type, expecting a string" + self.config_error(msg, key=f"{key}.{subkey}", got_type=type(item)) + return None + return val + + def ensure_people( + self, val: Sequence[ContactTable], key: str + ) -> list[tuple[str, str | None]]: + """Ensure that a value is a list of tables with optional "name" and "email" keys.""" + if not isinstance(val, list): + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + ) + self.config_error(msg, key=key, got_type=type(val)) + return [] + for each in val: + if not isinstance(each, dict): + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + " (got list with {type_name})" + ) + self.config_error(msg, key=key, type_name=type(each).__name__) + return [] + for value in each.values(): + if not isinstance(value, str): + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + " (got list with dict with {type_name})" + ) + self.config_error(msg, key=key, type_name=type(value).__name__) + return [] + extra_keys = set(each) - {"name", "email"} + if extra_keys: + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + " (got list with dict with extra keys {extra_keys})" + ) + self.config_error( + msg, + key=key, + extra_keys=", ".join(sorted(f'"{k}"' for k in extra_keys)), + ) + return [] + return [(entry.get("name", "Unknown"), entry.get("email")) for entry in val] + + def get_license( + self, project: ProjectTable, project_dir: pathlib.Path + ) -> License | str | None: + """Get the license field from the project table. Handles PEP 639 style license too. + + None is returned if the license field is not present or if an error occurred. + """ + val = project.get("license") + if val is None: + return None + if isinstance(val, str): + return val + + if isinstance(val, dict): + _license = self.ensure_dict(val, "project.license") # type: ignore[arg-type] + if _license is None: + return None + else: + msg = "Field {key} has an invalid type, expecting a string or table of strings" + self.config_error(msg, key="project.license", got_type=type(val)) + return None + + for field in _license: + if field not in ("file", "text"): + msg = "Unexpected field {key}" + self.config_error(msg, key=f"project.license.{field}") + return None + + file: pathlib.Path | None = None + filename = _license.get("file") + text = _license.get("text") + + if (filename and text) or (not filename and not text): + msg = ( + 'Invalid {key} contents, expecting a string or one key "file" or "text"' + ) + self.config_error(msg, key="project.license", got=_license) + return None + + if filename: + file = project_dir.joinpath(filename) + if not file.is_file(): + msg = f"License file not found ({filename!r})" + self.config_error(msg, key="project.license.file") + return None + text = file.read_text(encoding="utf-8") + + assert text is not None + return License(text, file) + + def get_license_files( + self, project: ProjectTable, project_dir: pathlib.Path + ) -> list[pathlib.Path] | None: + """Get the license-files list of files from the project table. + + Returns None if an error occurred (including invalid globs, etc) or if + not present. + """ + license_files = project.get("license-files") + if license_files is None: + return None + if self.ensure_list(license_files, "project.license-files") is None: + return None + + return list(self._get_files_from_globs(project_dir, license_files)) + + def get_readme( # noqa: C901 + self, project: ProjectTable, project_dir: pathlib.Path + ) -> Readme | None: + """Get the text of the readme from the project table. + + Returns None if an error occurred or if the readme field is not present. + """ + if "readme" not in project: + return None + + filename: str | None = None + file: pathlib.Path | None = None + text: str | None = None + content_type: str | None = None + + readme = project["readme"] + if isinstance(readme, str): + # readme is a file + text = None + filename = readme + if filename.endswith(".md"): + content_type = "text/markdown" + elif filename.endswith(".rst"): + content_type = "text/x-rst" + else: + msg = "Could not infer content type for readme file {filename!r}" + self.config_error(msg, key="project.readme", filename=filename) + return None + elif isinstance(readme, dict): + # readme is a dict containing either 'file' or 'text', and content-type + for field in readme: + if field not in ("content-type", "file", "text"): + msg = "Unexpected field {key}" + self.config_error(msg, key=f"project.readme.{field}") + return None + + content_type_raw = readme.get("content-type") + if content_type_raw is not None: + content_type = self.ensure_str( + content_type_raw, "project.readme.content-type" + ) + if content_type is None: + return None + filename_raw = readme.get("file") + if filename_raw is not None: + filename = self.ensure_str(filename_raw, "project.readme.file") + if filename is None: + return None + + text_raw = readme.get("text") + if text_raw is not None: + text = self.ensure_str(text_raw, "project.readme.text") + if text is None: + return None + + if (filename and text) or (not filename and not text): + msg = 'Invalid {key} contents, expecting either "file" or "text"' + self.config_error(msg, key="project.readme", got=readme) + return None + if not content_type: + msg = "Field {key} missing" + self.config_error(msg, key="project.readme.content-type") + return None + else: + msg = "Field {key} has an invalid type, expecting either a string or table of strings" + self.config_error(msg, key="project.readme", got_type=type(readme)) + return None + + if filename: + file = project_dir.joinpath(filename) + if not file.is_file(): + msg = "Readme file not found ({filename!r})" + self.config_error(msg, key="project.readme.file", filename=filename) + return None + text = file.read_text(encoding="utf-8") + + assert text is not None + return Readme(text, file, content_type) + + def get_dependencies(self, project: ProjectTable) -> list[Requirement]: + """Get the dependencies from the project table.""" + + requirement_strings: list[str] | None = None + requirement_strings_raw = project.get("dependencies") + if requirement_strings_raw is not None: + requirement_strings = self.ensure_list( + requirement_strings_raw, "project.dependencies" + ) + if requirement_strings is None: + return [] + + requirements: list[Requirement] = [] + for req in requirement_strings: + try: + requirements.append(packaging.requirements.Requirement(req)) + except packaging.requirements.InvalidRequirement as e: + msg = "Field {key} contains an invalid PEP 508 requirement string {req!r} ({error!r})" + self.config_error(msg, key="project.dependencies", req=req, error=e) + return [] + return requirements + + def get_optional_dependencies( + self, + project: ProjectTable, + ) -> dict[str, list[Requirement]]: + """Get the optional dependencies from the project table.""" + + val = project.get("optional-dependencies") + if not val: + return {} + + requirements_dict: dict[str, list[Requirement]] = {} + if not isinstance(val, dict): + msg = "Field {key} has an invalid type, expecting a table of PEP 508 requirement strings" + self.config_error( + msg, key="project.optional-dependencies", got_type=type(val) + ) + return {} + for extra, requirements in val.copy().items(): + assert isinstance(extra, str) + if not isinstance(requirements, list): + msg = "Field {key} has an invalid type, expecting a table of PEP 508 requirement strings" + self.config_error( + msg, + key=f"project.optional-dependencies.{extra}", + got_type=type(requirements), + ) + return {} + requirements_dict[extra] = [] + for req in requirements: + if not isinstance(req, str): + msg = "Field {key} has an invalid type, expecting a PEP 508 requirement string" + self.config_error( + msg, + key=f"project.optional-dependencies.{extra}", + got_type=type(req), + ) + return {} + try: + requirements_dict[extra].append( + packaging.requirements.Requirement(req) + ) + except packaging.requirements.InvalidRequirement as e: + msg = ( + "Field {key} contains " + "an invalid PEP 508 requirement string {req!r} ({error!r})" + ) + self.config_error( + msg, + key=f"project.optional-dependencies.{extra}", + req=req, + error=e, + ) + return {} + return dict(requirements_dict) + + def get_entrypoints(self, project: ProjectTable) -> dict[str, dict[str, str]]: + """Get the entrypoints from the project table.""" + + val = project.get("entry-points", None) + if val is None: + return {} + if not isinstance(val, dict): + msg = "Field {key} has an invalid type, expecting a table of entrypoint sections" + self.config_error(msg, key="project.entry-points", got_type=type(val)) + return {} + for section, entrypoints in val.items(): + assert isinstance(section, str) + if not re.match(r"^\w+(\.\w+)*$", section): + msg = ( + "Field {key} has an invalid value, expecting a name " + "containing only alphanumeric, underscore, or dot characters" + ) + self.config_error(msg, key="project.entry-points", got=section) + return {} + if not isinstance(entrypoints, dict): + msg = ( + "Field {key} has an invalid type, expecting a table of entrypoints" + ) + self.config_error( + msg, + key=f"project.entry-points.{section}", + got_type=type(entrypoints), + ) + return {} + for name, entrypoint in entrypoints.items(): + assert isinstance(name, str) + if not isinstance(entrypoint, str): + msg = "Field {key} has an invalid type, expecting a string" + self.config_error( + msg, + key=f"project.entry-points.{section}.{name}", + got_type=type(entrypoint), + ) + return {} + return val + + def get_dynamic(self, project: ProjectTable) -> list[Dynamic]: + """Get the dynamic fields from the project table. + + Returns an empty list if the field is not present or if an error occurred. + """ + dynamic = project.get("dynamic", []) + + self.ensure_list(dynamic, "project.dynamic") + + if "name" in dynamic: + msg = "Unsupported field 'name' in {key}" + self.config_error(msg, key="project.dynamic") + return [] + + return dynamic + + def _get_files_from_globs( + self, project_dir: pathlib.Path, globs: Iterable[str] + ) -> Generator[pathlib.Path, None, None]: + """Given a list of globs, get files that match.""" + + for glob in globs: + if glob.startswith(("..", "/")): + msg = "{glob!r} is an invalid {key} glob: the pattern must match files within the project directory" + self.config_error(msg, key="project.license-files", glob=glob) + break + files = [f for f in project_dir.glob(glob) if f.is_file()] + if not files: + msg = "Every pattern in {key} must match at least one file: {glob!r} did not match any" + self.config_error(msg, key="project.license-files", glob=glob) + break + for f in files: + yield f.relative_to(project_dir) diff --git a/src/pdm/backend/_vendor/vendor.txt b/src/pdm/backend/_vendor/vendor.txt index 9045851..9555374 100644 --- a/src/pdm/backend/_vendor/vendor.txt +++ b/src/pdm/backend/_vendor/vendor.txt @@ -1,5 +1,5 @@ packaging==24.1 tomli==2.0.1 tomli_w==1.0.0 -pyproject-metadata==0.9.0b4 +pyproject-metadata==0.9.0b7 editables==0.5