Source code for pyproject_metadata
# SPDX-License-Identifier: MIT
from __future__ import annotations
import collections
import copy
import dataclasses
import email.utils
import os
import os.path
import pathlib
import sys
import typing
if typing.TYPE_CHECKING:
from collections.abc import Mapping
from typing import Any
from 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
__version__ = '0.8.0'
KNOWN_METADATA_VERSIONS = {'2.1', '2.2', '2.3'}
[docs]
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
[docs]
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 __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()
[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) -> 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
[docs]
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 []
[docs]
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 {}
[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
)
):
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 []
[docs]
@dataclasses.dataclass
class StandardMetadata:
name: str
version: packaging.version.Version | None = None
description: str | None = None
license: License | 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)
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)
_metadata_version: str | None = None
@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
@property
def canonical_name(self) -> str:
return packaging.utils.canonicalize_name(self.name)
[docs]
@classmethod
def from_pyproject(
cls,
data: Mapping[str, Any],
project_dir: str | os.PathLike[str] = os.path.curdir,
metadata_version: str | None = None,
) -> Self:
fetcher = DataFetcher(data)
project_dir = pathlib.Path(project_dir)
if 'project' not in fetcher:
msg = 'Section "project" missing in pyproject.toml'
raise ConfigurationError(msg)
dynamic = fetcher.get_list('project.dynamic')
if 'name' in dynamic:
msg = 'Unsupported field "name" in "project.dynamic"'
raise ConfigurationError(msg)
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 = packaging.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')
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,
)
def _update_dynamic(self, value: Any) -> None:
if value and 'version' in self.dynamic:
self.dynamic.remove('version')
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)
[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'] = self.metadata_version
message['Name'] = self.name
if not self.version:
msg = 'Missing version field'
raise ConfigurationError(msg)
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)
if self.license:
message['License'] = self.license.text
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():
norm_extra = extra.replace('.', '-').replace('_', '-').lower()
message['Provides-Extra'] = norm_extra
for requirement in requirements:
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
# 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}'
raise ConfigurationError(msg)
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 _email_list(self, people: list[tuple[str, str]]) -> str:
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 = packaging.markers.Marker(
f'({requirement.marker}) and extra == "{extra}"'
)
else:
requirement.marker = packaging.markers.Marker(
f'{requirement.marker} and extra == "{extra}"'
)
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