Source code for pep621

# SPDX-License-Identifier: MIT

from __future__ import annotations

import collections
import dataclasses
import os
import os.path
import pathlib
import re
import typing

from typing import Any, DefaultDict, Dict, List, Mapping, Optional, OrderedDict, Tuple, Union

import packaging.markers
import packaging.requirements
import packaging.version


__version__ = '0.4.0'


[docs]class ConfigurationError(Exception): '''Error in the backend metadata.''' def __init__(self, msg: str, *, key: Optional[str] = None): super().__init__(msg) self._key = key @property def key(self) -> Optional[str]: # pragma: no cover return self._key
[docs]class RFC822Message(): '''Simple RFC 822 message implementation. Note: Does not support multiline fields, as Python packaging flavored RFC 822 metadata does. ''' def __init__(self) -> None: self.headers: OrderedDict[str, List[str]] = collections.OrderedDict() self.body: Optional[str] = None def __setitem__(self, name: str, value: Optional[str]) -> 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: text += f'{name}: {entry}\n' if self.body: text += '\n' + self.body return text def __bytes__(self) -> bytes: return str(self).encode()
[docs]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
[docs] def get(self, key: str) -> Any: val = self._data for part in key.split('.'): val = val[part] return val
[docs] def get_str(self, key: str) -> Optional[str]: try: val = self.get(key) if not isinstance(val, str): raise ConfigurationError( f'Field `{key}` has an invalid type, ' f'expecting a string (got `{val}`)', key=key, ) return val except KeyError: return None
[docs] def get_list(self, key: str) -> List[str]: try: val = self.get(key) if not isinstance(val, list): raise ConfigurationError( f'Field `{key}` has an invalid type, ' f'expecting a list of strings (got `{val}`)', key=val, ) for item in val: if not isinstance(item, str): raise ConfigurationError( f'Field `{key}` contains item with invalid type, ' f'expecting a string (got `{item}`)', key=key, ) return val except KeyError: return []
[docs] def get_dict(self, key: str) -> Dict[str, str]: try: val = self.get(key) if not isinstance(val, dict): raise ConfigurationError( f'Field `{key}` has an invalid type, ' f'expecting a dictionary of strings (got `{val}`)', key=key, ) for subkey, item in val.items(): if not isinstance(item, str): raise ConfigurationError( f'Field `{key}.{subkey}` has an invalid type, ' f'expecting a string (got `{item}`)', key=f'{key}.{subkey}', ) return val except KeyError: return {}
[docs] 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 ) ): raise ConfigurationError( f'Field `{key}` has an invalid type, expecting a list of ' f'dictionaries containing the `name` and/or `email` keys (got `{val}`)', key=key, ) return [ (entry.get('name', 'Unknown'), entry.get('email')) for entry in val ] except KeyError: return []
[docs]class License(typing.NamedTuple): text: str file: Optional[str]
[docs]class Readme(typing.NamedTuple): text: str file: Optional[str] content_type: str
[docs]@dataclasses.dataclass class StandardMetadata(): name: str version: Optional[packaging.version.Version] = None description: Optional[str] = None license: Optional[License] = None readme: Optional[Readme] = None requires_python: Optional[packaging.specifiers.Specifier] = None dependencies: List[packaging.requirements.Requirement] = dataclasses.field(default_factory=list) optional_dependencies: Dict[str, List[packaging.requirements.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) 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) def __post_init__(self) -> None: self.name = re.sub(r'[-_.]+', '-', self.name).lower()
[docs] @classmethod def from_pyproject( cls, data: Mapping[str, Any], project_dir: Union[str, os.PathLike[str]] = os.path.curdir, ) -> StandardMetadata: fetcher = DataFetcher(data) project_dir = pathlib.Path(project_dir) if 'project' not in fetcher: raise ConfigurationError('Section `project` missing in pyproject.toml') dynamic = fetcher.get_list('project.dynamic') if 'name' in dynamic: raise ConfigurationError('Unsupported field `name` in `project.dynamic`') name = fetcher.get_str('project.name') if not name: raise ConfigurationError('Field `project.name` missing') version_string = fetcher.get_str('project.version') requires_python_string = fetcher.get_str('project.requires-python') return cls( name, packaging.version.Version(version_string) if version_string else None, fetcher.get_str('project.description'), cls._get_license(fetcher, project_dir), cls._get_readme(fetcher, project_dir), packaging.specifiers.Specifier(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, )
[docs] def as_rfc822(self) -> RFC822Message: message = RFC822Message() self.write_to_rfc822(message) return message
[docs] def write_to_rfc822(self, message: RFC822Message) -> None: # noqa: C901 message['Metadata-Version'] = '2.2' if self.dynamic else '2.1' message['Name'] = self.name if not self.version: raise ConfigurationError('Missing version field') message['Version'] = str(self.version) # skip 'Platform' # skip 'Supported-Platform' if self.description: message['Summary'] = self.description message['Keywords'] = ' '.join(self.keywords) if 'homepage' in self.urls: 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) # TODO: 'License' for classifier in self.classifiers: 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}' if self.requires_python: message['Requires-Python'] = str(self.requires_python) for dep in self.dependencies: message['Requires-Dist'] = str(dep) for extra, requirements in self.optional_dependencies.items(): message['Provides-Extra'] = extra for requirement in requirements: message['Requires-Dist'] = str(self._build_extra_req(extra, requirement)) if self.readme: if self.readme.content_type: message['Description-Content-Type'] = self.readme.content_type message.body = self.readme.text # Core Metadata 2.2 for field in self.dynamic: if field in ('name', 'version'): raise ConfigurationError(f'Field cannot be dynamic: {field}') message['Dynamic'] = field
def _name_list(sefl, people: List[Tuple[str, str]]) -> str: return ', '.join( name for name, email_ in people if not email_ ) def _email_list(self, people: List[Tuple[str, str]]) -> str: return ', '.join([ '{}{}'.format(name, f' <{_email}>' if _email else '') for name, _email in people if _email ]) def _build_extra_req( self, extra: str, requirement: packaging.requirements.Requirement, ) -> packaging.requirements.Requirement: if requirement.marker: # append our extra to the marker requirement.marker = packaging.markers.Marker( str(requirement.marker) + f' and extra == "{extra}"' ) else: # add our extra marker requirement.marker = packaging.markers.Marker(f'extra == "{extra}"') return requirement @staticmethod def _get_license(fetcher: DataFetcher, project_dir: pathlib.Path) -> Optional[License]: if 'project.license' not in fetcher: return None _license = fetcher.get_dict('project.license') for field in _license: if field not in ('file', 'text'): raise ConfigurationError( f'Unexpected field `project.license.{field}`', key=f'project.license.{field}', ) file = fetcher.get_str('project.license.file') text = fetcher.get_str('project.license.text') if (file and text) or (not file and not text): raise ConfigurationError( f'Invalid `project.license` value, expecting either `file` or `text` (got `{_license}`)', key='project.license', ) if file: if not os.path.isfile(file): raise ConfigurationError( f'License file not found (`{file}`)', key='project.license.file', ) text = project_dir.joinpath(file).read_text() assert text is not None return License(text, file) @staticmethod def _get_readme(fetcher: DataFetcher, project_dir: pathlib.Path) -> Optional[Readme]: # noqa: C901 if 'project.readme' not in fetcher: return None file: Optional[str] text: Optional[str] content_type: Optional[str] readme = fetcher.get('project.readme') if isinstance(readme, str): # readme is a file text = None file = readme if file.endswith('.md'): content_type = 'text/markdown' elif file.endswith('.rst'): content_type = 'text/x-rst' else: raise ConfigurationError( f'Could not infer content type for readme file `{file}`', 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'): raise ConfigurationError( f'Unexpected field `project.readme.{field}`', key=f'project.readme.{field}', ) content_type = fetcher.get_str('project.readme.content-type') file = fetcher.get_str('project.readme.file') text = fetcher.get_str('project.readme.text') if (file and text) or (not file and not text): raise ConfigurationError( f'Invalid `project.readme` value, expecting either `file` or `text` (got `{readme}`)', key='project.license', ) if not content_type: raise ConfigurationError( 'Field `project.readme.content-type` missing', key='project.readme.content-type', ) else: raise ConfigurationError( f'Field `project.readme` has an invalid type, expecting either, ' f'a string or dictionary of strings (got `{readme}`)', key='project.readme', ) if file: if not os.path.isfile(file): raise ConfigurationError( f'Readme file not found (`{file}`)', key='project.license.file', ) text = project_dir.joinpath(file).read_text() assert text is not None return Readme(text, file, content_type) @staticmethod def _get_dependencies(fetcher: DataFetcher) -> List[packaging.requirements.Requirement]: try: requirement_strings = fetcher.get_list('project.dependencies') except KeyError: return [] requirements: List[packaging.requirements.Requirement] = [] for req in requirement_strings: try: requirements.append(packaging.requirements.Requirement(req)) except packaging.requirements.InvalidRequirement as e: raise ConfigurationError( 'Field `project.dependencies` contains an invalid PEP 508 ' f'requirement string `{req}` (`{str(e)}`)' ) return requirements @staticmethod def _get_optional_dependencies(fetcher: DataFetcher) -> Dict[str, List[packaging.requirements.Requirement]]: try: val = fetcher.get('project.optional-dependencies') except KeyError: return {} requirements_dict: DefaultDict[str, List[packaging.requirements.Requirement]] = collections.defaultdict(list) if not isinstance(val, dict): raise ConfigurationError( 'Field `project.optional-dependencies` has an invalid type, expecting a ' f'dictionary of PEP 508 requirement strings (got `{val}`)' ) for extra, requirements in val.copy().items(): assert isinstance(extra, str) if not isinstance(requirements, list): raise ConfigurationError( f'Field `project.optional-dependencies.{extra}` has an invalid type, expecting a ' f'dictionary PEP 508 requirement strings (got `{requirements}`)' ) for i, req in enumerate(requirements): if not isinstance(req, str): raise ConfigurationError( f'Field `project.optional-dependencies.{extra}` has an invalid type, ' f'expecting a PEP 508 requirement string (got `{req}`)' ) try: requirements_dict[extra].append(packaging.requirements.Requirement(req)) except packaging.requirements.InvalidRequirement as e: raise ConfigurationError( f'Field `project.optional-dependencies.{extra}` contains ' f'an invalid PEP 508 requirement string `{req}` (`{str(e)}`)' ) 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): raise ConfigurationError( 'Field `project.entry-points` has an invalid type, expecting a ' f'dictionary of entrypoint sections (got `{val}`)' ) for section, entrypoints in val.items(): assert isinstance(section, str) if not isinstance(entrypoints, dict): raise ConfigurationError( f'Field `project.entry-points.{section}` has an invalid type, expecting a ' f'dictionary of entrypoints (got `{entrypoints}`)' ) for name, entrypoint in entrypoints.items(): assert isinstance(name, str) if not isinstance(entrypoint, str): raise ConfigurationError( f'Field `project.entry-points.{section}.{name}` has an invalid type, ' f'expecting a string (got `{entrypoint}`)' ) return val