diff --git a/pyproject.toml b/pyproject.toml index a60650d8..281cd4df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ filterwarnings = [ "default:The distutils package is deprecated and slated for removal:DeprecationWarning", # Caused by setuptools sometimes "default:The distutils.sysconfig module is deprecated, use sysconfig instead:DeprecationWarning", # Caused by setuptools sometimes "default:check_home argument is deprecated and ignored.:DeprecationWarning", # Caused by setuptools sometimes + "ignore::scikit_build_core._vendor.pyproject_metadata.errors.ConfigurationWarning", ] log_cli_level = "info" pythonpath = ["tests/utils"] diff --git a/src/scikit_build_core/_vendor/pyproject_metadata/__init__.py b/src/scikit_build_core/_vendor/pyproject_metadata/__init__.py index 7ebc91dd..a8188244 100644 --- a/src/scikit_build_core/_vendor/pyproject_metadata/__init__.py +++ b/src/scikit_build_core/_vendor/pyproject_metadata/__init__.py @@ -2,15 +2,22 @@ from __future__ import annotations -import collections import copy import dataclasses +import email.message +import email.policy import email.utils import os import os.path import pathlib +import re import sys import typing +import warnings + +from . import constants +from .errors import ConfigurationError, ConfigurationWarning, ErrorCollector +from .pyproject import License, PyProjectReader, Readme if typing.TYPE_CHECKING: @@ -24,152 +31,155 @@ else: from typing import Self + from .project_table import PyProjectTable + import packaging.markers -import packaging.requirements import packaging.specifiers import packaging.utils import packaging.version -__version__ = '0.8.0' +__version__ = '0.9.0b5' -KNOWN_METADATA_VERSIONS = {'2.1', '2.2', '2.3'} +__all__ = [ + 'ConfigurationError', + 'ConfigurationWarning', + 'License', + 'RFC822Message', + 'RFC822Policy', + 'Readme', + 'StandardMetadata', + 'field_to_metadata', + 'validate_build_system', + 'validate_project', + 'validate_top_level', +] -class ConfigurationError(Exception): - '''Error in the backend metadata.''' - def __init__(self, msg: str, *, key: str | None = None): - super().__init__(msg) - self._key = key +def __dir__() -> list[str]: + return __all__ - @property - def key(self) -> str | None: # pragma: no cover - return self._key +def field_to_metadata(field: str) -> frozenset[str]: + """ + Return the METADATA fields that correspond to a project field. + """ + return frozenset(constants.PROJECT_TO_METADATA[field]) -class RFC822Message: - '''Python-flavored RFC 822 message implementation.''' - def __init__(self) -> None: - self.headers: collections.OrderedDict[str, list[str]] = collections.OrderedDict() - self.body: str | None = None +def validate_top_level(pyproject_table: Mapping[str, Any]) -> None: + extra_keys = set(pyproject_table) - constants.KNOWN_TOPLEVEL_FIELDS + if extra_keys: + extra_keys_str = ', '.join(sorted(f'"{k}"' for k in extra_keys)) + msg = f'Extra keys present in pyproject.toml: {extra_keys_str}' + raise ConfigurationError(msg) + + +def validate_build_system(pyproject_table: Mapping[str, Any]) -> None: + extra_keys = ( + set(pyproject_table.get('build-system', [])) + - constants.KNOWN_BUILD_SYSTEM_FIELDS + ) + if extra_keys: + extra_keys_str = ', '.join(sorted(f'"{k}"' for k in extra_keys)) + msg = f'Extra keys present in "build-system": {extra_keys_str}' + raise ConfigurationError(msg) + + +def validate_project(pyproject_table: Mapping[str, Any]) -> None: + extra_keys = ( + set(pyproject_table.get('project', [])) - constants.KNOWN_PROJECT_FIELDS + ) + if extra_keys: + extra_keys_str = ', '.join(sorted(f'"{k}"' for k in extra_keys)) + msg = f'Extra keys present in "project": {extra_keys_str}' + raise ConfigurationError(msg) + + +@dataclasses.dataclass +class _SmartMessageSetter: + """ + This provides a nice internal API for setting values in an Message to + reduce boilerplate. + + If a value is None, do nothing. + If a value contains a newline, indent it (may produce a warning in the future). + """ + + message: email.message.Message def __setitem__(self, name: str, value: str | None) -> None: if not value: return - if name not in self.headers: - self.headers[name] = [] - self.headers[name].append(value) - - def __str__(self) -> str: - text = '' - for name, entries in self.headers.items(): - for entry in entries: - lines = entry.strip('\n').split('\n') - text += f'{name}: {lines[0]}\n' - for line in lines[1:]: - text += ' ' * 8 + line + '\n' - if self.body: - text += '\n' + self.body - return text - - def __bytes__(self) -> bytes: - return str(self).encode() - - -class DataFetcher: - def __init__(self, data: Mapping[str, Any]) -> None: - self._data = data - - def __contains__(self, key: Any) -> bool: - if not isinstance(key, str): - return False - 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]: - 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 [] - - 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]]: - 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 [] + 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. 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 License(typing.NamedTuple): - text: str - file: pathlib.Path | None +class RFC822Policy(email.policy.EmailPolicy): + """ + This is `email.policy.EmailPolicy`, but with a simple ``header_store_parse`` + implementation that handles multiline values, and some nice defaults. + """ -class Readme(typing.NamedTuple): - text: str - file: pathlib.Path | None - content_type: str + utf8 = True + mangle_from_ = False + max_line_length = 0 + + def header_store_parse(self, name: str, value: str) -> tuple[str, str]: + if name.lower() not in constants.KNOWN_METADATA_FIELDS: + msg = f'Unknown field "{name}"' + raise ConfigurationError(msg, key=name) + size = len(name) + 2 + 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 + our `RFC822Policy`, and it correctly writes unicode when being called + with `bytes()`. + """ + + def __init__(self) -> None: + super().__init__(policy=RFC822Policy()) + + def as_bytes( + self, unixfrom: bool = False, policy: email.policy.Policy | None = None + ) -> bytes: + return self.as_string(unixfrom, policy=policy).encode('utf-8') @dataclasses.dataclass @@ -177,176 +187,351 @@ class StandardMetadata: name: str version: packaging.version.Version | None = None description: str | None = None - license: License | None = None + license: License | str | None = None + license_files: list[pathlib.Path] | None = None readme: Readme | None = None requires_python: packaging.specifiers.SpecifierSet | None = None dependencies: list[Requirement] = dataclasses.field(default_factory=list) - optional_dependencies: dict[str, list[Requirement]] = dataclasses.field(default_factory=dict) + optional_dependencies: dict[str, list[Requirement]] = dataclasses.field( + default_factory=dict + ) entrypoints: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict) - authors: list[tuple[str, str]] = dataclasses.field(default_factory=list) - maintainers: list[tuple[str, str]] = dataclasses.field(default_factory=list) + authors: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) + maintainers: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) urls: dict[str, str] = dataclasses.field(default_factory=dict) classifiers: list[str] = dataclasses.field(default_factory=list) keywords: list[str] = dataclasses.field(default_factory=list) scripts: dict[str, str] = dataclasses.field(default_factory=dict) gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict) dynamic: list[str] = 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+. + """ + + metadata_version: str | None = None + all_errors: bool = False + _locked_metadata: bool = False + + 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) + super().__setattr__(name, value) + + def validate(self, *, warn: bool = True) -> None: # noqa: C901 + errors = ErrorCollector(collect_errors=self.all_errors) + + if self.auto_metadata_version not in constants.KNOWN_METADATA_VERSIONS: + msg = f'The metadata_version must be one of {constants.KNOWN_METADATA_VERSIONS} or None (default)' + errors.config_error(msg) - _metadata_version: str | None = None + # 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 + ): + 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' + ) + errors.config_error(msg, key='project.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' + errors.config_error(msg, key='project.license-files') + + if isinstance(self.license, str) and any( + c.startswith('License ::') for c in self.classifiers + ): + msg = 'Setting "project.license" to an SPDX license expression is not compatible with "License ::" classifiers' + errors.config_error(msg, key='project.license') + + if warn: + if self.description and '\n' in self.description: + warnings.warn( + 'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.', + ConfigurationWarning, + stacklevel=2, + ) + if self.auto_metadata_version not in constants.PRE_SPDX_METADATA_VERSIONS: + if isinstance(self.license, License): + warnings.warn( + 'Set "project.license" to an SPDX license expression for metadata >= 2.4', + ConfigurationWarning, + stacklevel=2, + ) + elif any(c.startswith('License ::') for c in self.classifiers): + warnings.warn( + '"License ::" classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for "project.license" instead', + ConfigurationWarning, + stacklevel=2, + ) + + if ( + isinstance(self.license, str) + 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' + errors.config_error(msg, key='project.license') + + if ( + self.license_files is not None + and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS + ): + msg = '"project.license-files" is supported only when emitting metadata version >= 2.4' + errors.config_error(msg, key='project.license-files') + + errors.finalize('Metadata validation failed') @property - def metadata_version(self) -> str: - if self._metadata_version is None: - return '2.2' if self.dynamic else '2.1' - return self._metadata_version + 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 packaging.utils.canonicalize_name(self.name) @classmethod - def from_pyproject( + 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: - fetcher = DataFetcher(data) - project_dir = pathlib.Path(project_dir) + pyproject = PyProjectReader(collect_errors=all_errors) - if 'project' not in fetcher: + pyproject_table: PyProjectTable = data # type: ignore[assignment] + if 'project' not in pyproject_table: msg = 'Section "project" missing in pyproject.toml' - raise ConfigurationError(msg) + 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 - dynamic = fetcher.get_list('project.dynamic') - if 'name' in dynamic: - msg = 'Unsupported field "name" in "project.dynamic"' - raise ConfigurationError(msg) + project = pyproject_table['project'] + project_dir = pathlib.Path(project_dir) + + 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: + with pyproject.collect(): + validate_project(data) + + dynamic = pyproject.get_dynamic(project) 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) + pyproject.config_error(msg, key=field) - name = fetcher.get_str('project.name') - if not name: + raw_name = project.get('name') + name = 'UNKNOWN' + if raw_name is None: 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 = packaging.version.Version(version_string) if version_string else None - - if version is None and 'version' not in dynamic: + pyproject.config_error(msg, key='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: + with pyproject.collect(): + version = ( + packaging.version.Version(version_string) + if version_string + else None + ) + elif 'version' not in dynamic: msg = 'Field "project.version" missing and "version" not specified in "project.dynamic"' - raise ConfigurationError(msg) + pyproject.config_error(msg, key='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. - description = fetcher.get_str('project.description') - - if metadata_version and 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) - - return cls( - name, - version, - description, - cls._get_license(fetcher, project_dir), - cls._get_readme(fetcher, project_dir), - packaging.specifiers.SpecifierSet(requires_python_string) if requires_python_string else None, - cls._get_dependencies(fetcher), - cls._get_optional_dependencies(fetcher), - cls._get_entrypoints(fetcher), - fetcher.get_people('project.authors'), - fetcher.get_people('project.maintainers'), - fetcher.get_dict('project.urls'), - fetcher.get_list('project.classifiers'), - fetcher.get_list('project.keywords'), - fetcher.get_dict('project.scripts'), - fetcher.get_dict('project.gui-scripts'), - dynamic, - metadata_version, + project_description_raw = project.get('description') + description = ( + pyproject.ensure_str(project_description_raw, 'project.description') + if project_description_raw is not None + else None ) - def _update_dynamic(self, value: Any) -> None: - if value and 'version' in self.dynamic: - self.dynamic.remove('version') + 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: + with pyproject.collect(): + requires_python = packaging.specifiers.SpecifierSet( + 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 - def __setattr__(self, name: str, value: Any) -> None: - # update dynamic when version is set - if name == 'version' and hasattr(self, 'dynamic'): - self._update_dynamic(value) - super().__setattr__(name, value) + pyproject.finalize('Failed to parse pyproject.toml') + assert self is not None + return self def as_rfc822(self) -> RFC822Message: message = RFC822Message() - self.write_to_rfc822(message) + smart_message = _SmartMessageSetter(message) + self._write_metadata(smart_message) + return message + + def as_json(self) -> dict[str, str | list[str]]: + message: dict[str, str | list[str]] = {} + smart_message = _JSonMessageSetter(message) + self._write_metadata(smart_message) return message - def write_to_rfc822(self, message: RFC822Message) -> None: # noqa: C901 - message['Metadata-Version'] = self.metadata_version - message['Name'] = self.name + def _write_metadata( # noqa: C901 + self, smart_message: _SmartMessageSetter | _JSonMessageSetter + ) -> None: + self.validate(warn=False) + + smart_message['Metadata-Version'] = self.auto_metadata_version + smart_message['Name'] = self.name if not self.version: msg = 'Missing version field' raise ConfigurationError(msg) - message['Version'] = str(self.version) + smart_message['Version'] = str(self.version) # skip 'Platform' # skip 'Supported-Platform' if self.description: - message['Summary'] = self.description - message['Keywords'] = ','.join(self.keywords) + smart_message['Summary'] = self.description + smart_message['Keywords'] = ','.join(self.keywords) or None if 'homepage' in self.urls: - message['Home-page'] = self.urls['homepage'] + smart_message['Home-page'] = self.urls['homepage'] # skip 'Download-URL' - message['Author'] = self._name_list(self.authors) - message['Author-Email'] = self._email_list(self.authors) - message['Maintainer'] = self._name_list(self.maintainers) - message['Maintainer-Email'] = self._email_list(self.maintainers) - if self.license: - message['License'] = self.license.text + 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) + + if isinstance(self.license, License): + smart_message['License'] = self.license.text + elif isinstance(self.license, str): + smart_message['License-Expression'] = self.license + + if self.license_files is not None: + for license_file in sorted(set(self.license_files)): + smart_message['License-File'] = os.fspath(license_file.as_posix()) + for classifier in self.classifiers: - message['Classifier'] = classifier + smart_message['Classifier'] = classifier # skip 'Provides-Dist' # skip 'Obsoletes-Dist' # skip 'Requires-External' for name, url in self.urls.items(): - message['Project-URL'] = f'{name.capitalize()}, {url}' + smart_message['Project-URL'] = f'{name.capitalize()}, {url}' if self.requires_python: - message['Requires-Python'] = str(self.requires_python) + smart_message['Requires-Python'] = str(self.requires_python) for dep in self.dependencies: - 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() - message['Provides-Extra'] = norm_extra + smart_message['Provides-Extra'] = norm_extra for requirement in requirements: - message['Requires-Dist'] = str(self._build_extra_req(norm_extra, requirement)) + smart_message['Requires-Dist'] = str( + self._build_extra_req(norm_extra, requirement) + ) if self.readme: if self.readme.content_type: - message['Description-Content-Type'] = self.readme.content_type - message.body = 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.metadata_version != '2.1': - for field in self.dynamic: - if field in ('name', 'version'): - msg = f'Field cannot be dynamic: {field}' + 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}' + raise ConfigurationError(msg) + if field.lower() not in constants.KNOWN_METADATA_FIELDS: + msg = f'Field is not known: {field}' raise ConfigurationError(msg) - message['Dynamic'] = field + smart_message['Dynamic'] = field - def _name_list(self, people: list[tuple[str, str]]) -> str: - return ', '.join( - name - for name, email_ in people - if not email_ - ) + def _name_list(self, people: list[tuple[str, str | None]]) -> str | None: + return ', '.join(name for name, email_ in people if not email_) or None - def _email_list(self, people: list[tuple[str, str]]) -> str: - return ', '.join( - email.utils.formataddr((name, _email)) - for name, _email in people - if _email + def _email_list(self, people: list[tuple[str, str | None]]) -> str | None: + return ( + ', '.join( + email.utils.formataddr((name, _email)) + for name, _email in people + if _email + ) + or None ) def _build_extra_req( @@ -368,175 +553,3 @@ def _build_extra_req( else: requirement.marker = packaging.markers.Marker(f'extra == "{extra}"') return requirement - - @staticmethod - def _get_license(fetcher: DataFetcher, project_dir: pathlib.Path) -> License | None: - if 'project.license' not in fetcher: - return None - - _license = fetcher.get_dict('project.license') - 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 = fetcher.get_str('project.license.file') - text = fetcher.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) - - @staticmethod - def _get_readme(fetcher: DataFetcher, project_dir: pathlib.Path) -> Readme | None: # noqa: C901 - if 'project.readme' not in fetcher: - return None - - filename: str | None - file: pathlib.Path | None = None - text: str | None - content_type: str | None - - readme = fetcher.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 = fetcher.get_str('project.readme.content-type') - filename = fetcher.get_str('project.readme.file') - text = fetcher.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) - - @staticmethod - def _get_dependencies(fetcher: DataFetcher) -> list[Requirement]: - try: - requirement_strings = fetcher.get_list('project.dependencies') - except KeyError: - return [] - - requirements: list[Requirement] = [] - for req in requirement_strings: - try: - requirements.append(packaging.requirements.Requirement(req)) - except packaging.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 - - @staticmethod - def _get_optional_dependencies(fetcher: DataFetcher) -> dict[str, list[Requirement]]: - try: - val = fetcher.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(packaging.requirements.Requirement(req)) - except packaging.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) - - @staticmethod - def _get_entrypoints(fetcher: DataFetcher) -> dict[str, dict[str, str]]: - try: - val = fetcher.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 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 diff --git a/src/scikit_build_core/_vendor/pyproject_metadata/constants.py b/src/scikit_build_core/_vendor/pyproject_metadata/constants.py new file mode 100644 index 00000000..4d636eca --- /dev/null +++ b/src/scikit_build_core/_vendor/pyproject_metadata/constants.py @@ -0,0 +1,97 @@ +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/scikit_build_core/_vendor/pyproject_metadata/errors.py b/src/scikit_build_core/_vendor/pyproject_metadata/errors.py new file mode 100644 index 00000000..79d5705f --- /dev/null +++ b/src/scikit_build_core/_vendor/pyproject_metadata/errors.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import builtins +import contextlib +import dataclasses +import sys +import typing + +import packaging.specifiers +import packaging.version + + +__all__ = [ + 'ConfigurationError', + 'ConfigurationWarning', + 'ExceptionGroup', + 'ErrorCollector', +] + + +def __dir__() -> list[str]: + return __all__ + + +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 + + +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.""" + + 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: bool + errors: list[Exception] = dataclasses.field(default_factory=list) + + def config_error(self, msg: str, key: str | None = None) -> None: + """Raise a configuration error, or add it to the error list.""" + if 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]: + if self.collect_errors: + try: + yield + except ( + ConfigurationError, + packaging.version.InvalidVersion, + packaging.specifiers.InvalidSpecifier, + ) as error: + self.errors.append(error) + except ExceptionGroup as error: + self.errors.extend(error.exceptions) + else: + yield diff --git a/src/scikit_build_core/_vendor/pyproject_metadata/project_table.py b/src/scikit_build_core/_vendor/pyproject_metadata/project_table.py new file mode 100644 index 00000000..d0e12179 --- /dev/null +++ b/src/scikit_build_core/_vendor/pyproject_metadata/project_table.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: MIT + +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__ = [ + 'ContactTable', + 'LicenseTable', + 'ReadmeTable', + 'ProjectTable', + 'BuildSystemTable', + 'PyProjectTable', +] + + +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 +) + +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[ + Literal[ + 'authors', + 'classifiers', + 'dependencies', + 'description', + 'dynamic', + 'entry-points', + 'gui-scripts', + 'keywords', + 'license', + 'maintainers', + 'optional-dependencies', + 'readme', + 'requires-python', + 'scripts', + 'urls', + 'version', + ] + ], + }, + 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/scikit_build_core/_vendor/pyproject_metadata/py.typed b/src/scikit_build_core/_vendor/pyproject_metadata/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/scikit_build_core/_vendor/pyproject_metadata/pyproject.py b/src/scikit_build_core/_vendor/pyproject_metadata/pyproject.py new file mode 100644 index 00000000..ef29afda --- /dev/null +++ b/src/scikit_build_core/_vendor/pyproject_metadata/pyproject.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import dataclasses +import pathlib +import re +import typing + +import packaging.requirements + +from .errors import ErrorCollector + + +__all__ = [ + 'License', + 'Readme', +] + + +def __dir__() -> list[str]: + return __all__ + + +@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 + + +if typing.TYPE_CHECKING: + from collections.abc import Generator, Iterable, Sequence + + from packaging.requirements import Requirement + + from .project_table import ContactTable, ProjectTable + + +@dataclasses.dataclass +class PyProjectReader(ErrorCollector): + def ensure_str(self, value: str, key: str) -> str | None: + if isinstance(value, str): + return value + + msg = f'Field "{key}" has an invalid type, expecting a string (got "{value}")' + self.config_error(msg, key=key) + return None + + def ensure_list(self, val: list[str], key: str) -> list[str] | None: + if not isinstance(val, list): + msg = f'Field "{key}" has an invalid type, expecting a list of strings (got "{val}")' + self.config_error(msg, key=key) + return None + for item in val: + if not isinstance(item, str): + msg = f'Field "{key}" contains item with invalid type, expecting a string (got "{item}")' + self.config_error(msg, key=key) + return None + + return val + + def ensure_dict(self, val: dict[str, str], key: str) -> dict[str, str] | None: + if not isinstance(val, dict): + msg = f'Field "{key}" has an invalid type, expecting a dictionary of strings (got "{val}")' + self.config_error(msg, key=key) + return None + 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}")' + self.config_error(msg, key=f'{key}.{subkey}') + return None + return val + + def ensure_people( + self, val: Sequence[ContactTable], key: str + ) -> list[tuple[str, str | None]]: + 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}")' + ) + self.config_error(msg, key=key) + 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: + 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 = f'Field "project.license" has an invalid type, expecting a string or dictionary of strings (got "{val}")' + self.config_error(msg, key='project.license') + return None + + for field in _license: + if field not in ('file', 'text'): + msg = f'Unexpected field "project.license.{field}"' + 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 = f'Invalid "project.license" value, expecting either "file" or "text" (got "{_license}")' + self.config_error(msg, key='project.license') + return None + + if filename: + file = project_dir.joinpath(filename) + if not file.is_file(): + msg = f'License file not found ("{filename}")' + 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: + 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: + 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 = f'Could not infer content type for readme file "{filename}"' + self.config_error(msg, key='project.readme') + 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 = f'Unexpected field "project.readme.{field}"' + 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 = f'Invalid "project.readme" value, expecting either "file" or "text" (got "{readme}")' + self.config_error(msg, key='project.readme') + return None + if not content_type: + msg = 'Field "project.readme.content-type" missing' + self.config_error(msg, key='project.readme.content-type') + return None + else: + msg = ( + f'Field "project.readme" has an invalid type, expecting either, ' + f'a string or dictionary of strings (got "{readme}")' + ) + self.config_error(msg, key='project.readme') + return None + + if filename: + file = project_dir.joinpath(filename) + if not file.is_file(): + msg = f'Readme file not found ("{filename}")' + self.config_error(msg, key='project.readme.file') + 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]: + 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 "project.dependencies" contains an invalid PEP 508 ' + f'requirement string "{req}" ("{e}")' + ) + self.config_error(msg, key='project.dependencies') + return [] + return requirements + + def get_optional_dependencies( + self, + project: ProjectTable, + ) -> dict[str, list[Requirement]]: + val = project.get('optional-dependencies') + if not val: + 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}")' + ) + self.config_error(msg, key='project.optional-dependencies') + return {} + 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}")' + ) + self.config_error(msg, key=f'project.optional-dependencies.{extra}') + return {} + 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}")' + ) + self.config_error(msg, key=f'project.optional-dependencies.{extra}') + return {} + try: + requirements_dict[extra].append( + packaging.requirements.Requirement(req) + ) + except packaging.requirements.InvalidRequirement as e: + msg = ( + f'Field "project.optional-dependencies.{extra}" contains ' + f'an invalid PEP 508 requirement string "{req}" ("{e}")' + ) + self.config_error(msg, key=f'project.optional-dependencies.{extra}') + return {} + return dict(requirements_dict) + + def get_entrypoints(self, project: ProjectTable) -> dict[str, dict[str, str]]: + val = project.get('entry-points', None) + if val is None: + return {} + if not isinstance(val, dict): + msg = ( + 'Field "project.entry-points" has an invalid type, expecting a ' + f'dictionary of entrypoint sections (got "{val}")' + ) + self.config_error(msg, key='project.entry-points') + return {} + 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}")' + ) + self.config_error(msg, key='project.entry-points') + return {} + if not isinstance(entrypoints, dict): + msg = ( + f'Field "project.entry-points.{section}" has an invalid type, expecting a ' + f'dictionary of entrypoints (got "{entrypoints}")' + ) + self.config_error(msg, key=f'project.entry-points.{section}') + return {} + 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}")' + ) + self.config_error(msg, key=f'project.entry-points.{section}.{name}') + return {} + return val + + def get_dynamic(self, project: ProjectTable) -> list[str]: + dynamic: list[str] = project.get('dynamic', []) # type: ignore[assignment] + + self.ensure_list(dynamic, 'project.dynamic') + + if 'name' in dynamic: + msg = 'Unsupported field "name" in "project.dynamic"' + 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]: + 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' + self.config_error(msg) + break + 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' + self.config_error(msg) + break + for f in files: + yield f.relative_to(project_dir) diff --git a/src/scikit_build_core/build/metadata.py b/src/scikit_build_core/build/metadata.py index fc788bb0..767bc88b 100644 --- a/src/scikit_build_core/build/metadata.py +++ b/src/scikit_build_core/build/metadata.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import dataclasses from typing import TYPE_CHECKING, Any from packaging.version import Version @@ -43,7 +44,7 @@ def get_standard_metadata( if settings.minimum_version is not None and settings.minimum_version < Version( "0.5" ): - metadata.name = metadata.canonical_name + metadata = dataclasses.replace(metadata, name=metadata.canonical_name) # The description field is required to be one line. Instead of merging it # or cutting off subsequent lines (setuptools), we throw a nice error. diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py index a389df04..a0bef302 100644 --- a/src/scikit_build_core/build/wheel.py +++ b/src/scikit_build_core/build/wheel.py @@ -314,7 +314,11 @@ def _build_wheel_impl_impl( # Include the metadata license.file entry if provided license_file_globs = list(settings.wheel.license_files) - if metadata.license and metadata.license.file: + if ( + metadata.license + and not isinstance(metadata.license, str) + and metadata.license.file + ): license_file_globs.append(str(metadata.license.file)) for y in license_file_globs: diff --git a/tests/conftest.py b/tests/conftest.py index b3ee569f..1262d625 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -211,10 +211,10 @@ def package_simple_pyproject_ext( ) -> PackageInfo: package = PackageInfo( "simple_pyproject_ext", - "5544d96810ce60ac40baf28cf8caf2e1e7e1fa7439b283d3fb52cdc1f87f12ac", - "aaa15c185dc3fbc301dc2fca937cc935442c38e55bc400fbefd424bd6ce92adb", - "ee3a564a37c42df3abdcee3862175baceeb6f6eff0b29931681b424ec5d96067", - "4c1d402621e7f00fce4ce5afdb73a9ba4cc25cd4bb57619113432841f779dd68", + "72cd37019e113cbabebdceb79bc867f8e1a4fc7323b6a272e6a77d6ea384d3e8", + "be15157c8659f258b73eb474616473d60945c4d45693796b55234ec83893263a", + "d97cd496aa6c46df2caf5064a7765588c831b8db9d7b46a536026ed951ce724a", + "b1182a2aa7a2b81365b3ad7ae1839b20d983ef10b6c3de16b681c23f536ca1b7", ) process_package(package, tmp_path, monkeypatch) return package diff --git a/tests/test_pyproject_pep517.py b/tests/test_pyproject_pep517.py index 7977c918..5f2863a7 100644 --- a/tests/test_pyproject_pep517.py +++ b/tests/test_pyproject_pep517.py @@ -35,6 +35,7 @@ Requires-Python: >=3.7 Provides-Extra: test Requires-Dist: pytest>=6.0; extra == "test" + """ mark_hashes_different = pytest.mark.xfail( diff --git a/tests/test_pyproject_pep518.py b/tests/test_pyproject_pep518.py index 70c6955a..49794b2b 100644 --- a/tests/test_pyproject_pep518.py +++ b/tests/test_pyproject_pep518.py @@ -34,6 +34,7 @@ def test_pep518_sdist(isolated, package_simple_pyproject_ext): Requires-Python: >=3.7 Provides-Extra: test Requires-Dist: pytest>=6.0; extra == "test" + """ ) @@ -76,6 +77,7 @@ def test_pep518_sdist_with_cmake_config(isolated, cleanup_overwrite): Metadata-Version: 2.1 Name: sdist_config Version: 0.1.0 + """ ) diff --git a/tests/test_wheelfile_utils.py b/tests/test_wheelfile_utils.py index 9e92ac57..b38d7b0a 100644 --- a/tests/test_wheelfile_utils.py +++ b/tests/test_wheelfile_utils.py @@ -46,7 +46,7 @@ def test_wheel_writer_simple(tmp_path, monkeypatch): dist_info = wheel.dist_info_contents() assert dist_info == { - "METADATA": b"Metadata-Version: 2.1\nName: something\nVersion: 1.2.3\n", + "METADATA": b"Metadata-Version: 2.1\nName: something\nVersion: 1.2.3\n\n", "WHEEL": b"Wheel-Version: 1.0\nGenerator: scikit-build-core 1.2.3\nRoot-Is-Purelib: false\nTag: py3-none-any\n\n", }