Source code for pyproject_metadata.pyproject

# SPDX-License-Identifier: MIT

"""
This module focuses 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.

These functions return None if an error occurred, and use the ErrorCollector to
record errors.
"""

from __future__ import annotations

import dataclasses
import pathlib
import typing
from typing import Any

import packaging.requirements

if typing.TYPE_CHECKING:
    from collections.abc import Generator, Iterable

    from packaging.requirements import Requirement

    from .errors import ErrorCollector
    from .project_table import ProjectTable


__all__ = [
    "License",
    "Readme",
]


def __dir__() -> list[str]:
    return __all__


[docs] @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
[docs] @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
def ensure_str(value: object) -> str | None: """Ensure that a value is a string.""" if isinstance(value, str): return value return None def ensure_list(val: object) -> list[str] | None: """Ensure that a value is a list of strings.""" if val is None: return None if not isinstance(val, list): return None for item in val: if not isinstance(item, str): return None return val def ensure_dict(val: object) -> dict[str, str] | None: """Ensure that a value is a dictionary of strings.""" if not isinstance(val, dict): return None for item in val.values(): if not isinstance(item, str): return None return val def ensure_people(val: object) -> 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): return [] for each in val: if not isinstance(each, dict): return [] for value in each.values(): if not isinstance(value, str): return [] return [(entry.get("name", "Unknown"), entry.get("email")) for entry in val] def get_license( project: ProjectTable, project_dir: pathlib.Path, error_collector: ErrorCollector ) -> 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 = ensure_dict(val) if _license is None: return None else: 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): return None if filename: file = project_dir.joinpath(filename) if not file.is_file(): msg = "License file not found ({filename!r})" error_collector.config_error( msg, key="project.license.file", filename=filename ) return None text = file.read_text(encoding="utf-8") assert text is not None return License(text, file) def get_license_files( project: ProjectTable, project_dir: pathlib.Path, error_collector: ErrorCollector ) -> 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 ensure_list(license_files) is None: return None return list(_get_files_from_globs(project_dir, license_files, error_collector)) def get_readme( # noqa: C901 project: ProjectTable, project_dir: pathlib.Path, error_collector: ErrorCollector ) -> 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}" error_collector.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"): return None content_type_raw = readme.get("content-type") if content_type_raw is not None: content_type = ensure_str(content_type_raw) if content_type is None: return None filename_raw = readme.get("file") if filename_raw is not None: filename = ensure_str(filename_raw) if filename is None: return None text_raw = readme.get("text") if text_raw is not None: text = ensure_str(text_raw) if text is None: return None if (filename and text) or (not filename and not text): return None if not content_type: return None else: return None if filename: file = project_dir.joinpath(filename) if not file.is_file(): msg = "Readme file not found ({filename!r})" error_collector.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(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 = ensure_list(requirement_strings_raw) if requirement_strings is None: return [] requirements: list[Requirement] = [] try: requirements.extend( packaging.requirements.Requirement(req) for req in requirement_strings ) except packaging.requirements.InvalidRequirement: return [] return requirements def get_optional_dependencies( 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): return {} for extra, requirements in val.items(): assert isinstance(extra, str) if not isinstance(requirements, list): return {} requirements_dict[extra] = [] for req in requirements: if not isinstance(req, str): return {} try: requirements_dict[extra].append(packaging.requirements.Requirement(req)) except packaging.requirements.InvalidRequirement: return {} return requirements_dict def get_entrypoints(project: ProjectTable) -> dict[str, dict[str, str]]: """Get the entrypoints from the project table.""" val = project.get("entry-points") if val is None: return {} if not isinstance(val, dict): return {} for entrypoints in val.values(): if not isinstance(entrypoints, dict): return {} for entrypoint in entrypoints.values(): if not isinstance(entrypoint, str): return {} return val def get_dynamic(project: dict[str, Any]) -> list[str]: """Get the dynamic fields from the project table. Returns an empty list if the field is not present or if an error occurred. """ return ensure_list(project.get("dynamic", [])) or [] def _get_files_from_globs( project_dir: pathlib.Path, globs: Iterable[str], error_collector: ErrorCollector ) -> Generator[pathlib.Path, None, None]: """Given a list of globs, get files that match.""" for glob in globs: # Reject any glob that escapes the project directory: absolute paths # (POSIX or Windows) or any pattern containing a ".." path segment. pure_path = pathlib.PurePosixPath(glob) if ( pure_path.is_absolute() or pathlib.PureWindowsPath(glob).is_absolute() or ".." in pure_path.parts or ".." in pathlib.PureWindowsPath(glob).parts ): msg = "{glob!r} is an invalid {key} glob: the pattern must match files within the project directory" error_collector.config_error(msg, key="project.license-files", glob=glob) continue 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" error_collector.config_error(msg, key="project.license-files", glob=glob) continue for f in files: yield f.relative_to(project_dir)