Path: blob/main/test/lib/python3.9/site-packages/setuptools/config/pyprojecttoml.py
4799 views
"""Load setuptools configuration from ``pyproject.toml`` files"""1import logging2import os3import warnings4from contextlib import contextmanager5from functools import partial6from typing import TYPE_CHECKING, Callable, Dict, Optional, Mapping, Union78from setuptools.errors import FileError, OptionError910from . import expand as _expand11from ._apply_pyprojecttoml import apply as _apply12from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _WouldIgnoreField1314if TYPE_CHECKING:15from setuptools.dist import Distribution # noqa1617_Path = Union[str, os.PathLike]18_logger = logging.getLogger(__name__)192021def load_file(filepath: _Path) -> dict:22from setuptools.extern import tomli # type: ignore2324with open(filepath, "rb") as file:25return tomli.load(file)262728def validate(config: dict, filepath: _Path) -> bool:29from . import _validate_pyproject as validator3031trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")32if hasattr(trove_classifier, "_disable_download"):33# Improve reproducibility by default. See issue 31 for validate-pyproject.34trove_classifier._disable_download() # type: ignore3536try:37return validator.validate(config)38except validator.ValidationError as ex:39_logger.error(f"configuration error: {ex.summary}") # type: ignore40_logger.debug(ex.details) # type: ignore41error = ValueError(f"invalid pyproject.toml config: {ex.name}") # type: ignore42raise error from None434445def apply_configuration(46dist: "Distribution",47filepath: _Path,48ignore_option_errors=False,49) -> "Distribution":50"""Apply the configuration from a ``pyproject.toml`` file into an existing51distribution object.52"""53config = read_configuration(filepath, True, ignore_option_errors, dist)54return _apply(dist, config, filepath)555657def read_configuration(58filepath: _Path,59expand=True,60ignore_option_errors=False,61dist: Optional["Distribution"] = None,62):63"""Read given configuration file and returns options from it as a dict.6465:param str|unicode filepath: Path to configuration file in the ``pyproject.toml``66format.6768:param bool expand: Whether to expand directives and other computed values69(i.e. post-process the given configuration)7071:param bool ignore_option_errors: Whether to silently ignore72options, values of which could not be resolved (e.g. due to exceptions73in directives such as file:, attr:, etc.).74If False exceptions are propagated as expected.7576:param Distribution|None: Distribution object to which the configuration refers.77If not given a dummy object will be created and discarded after the78configuration is read. This is used for auto-discovery of packages in the case79a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.80When ``expand=False`` this object is simply ignored.8182:rtype: dict83"""84filepath = os.path.abspath(filepath)8586if not os.path.isfile(filepath):87raise FileError(f"Configuration file {filepath!r} does not exist.")8889asdict = load_file(filepath) or {}90project_table = asdict.get("project", {})91tool_table = asdict.get("tool", {})92setuptools_table = tool_table.get("setuptools", {})93if not asdict or not (project_table or setuptools_table):94return {} # User is not using pyproject to configure setuptools9596# TODO: Remove the following once the feature stabilizes:97msg = (98"Support for project metadata in `pyproject.toml` is still experimental "99"and may be removed (or change) in future releases."100)101warnings.warn(msg, _ExperimentalProjectMetadata)102103# There is an overall sense in the community that making include_package_data=True104# the default would be an improvement.105# `ini2toml` backfills include_package_data=False when nothing is explicitly given,106# therefore setting a default here is backwards compatible.107orig_setuptools_table = setuptools_table.copy()108if dist and getattr(dist, "include_package_data") is not None:109setuptools_table.setdefault("include-package-data", dist.include_package_data)110else:111setuptools_table.setdefault("include-package-data", True)112# Persist changes:113asdict["tool"] = tool_table114tool_table["setuptools"] = setuptools_table115116try:117# Don't complain about unrelated errors (e.g. tools not using the "tool" table)118subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}119validate(subset, filepath)120except Exception as ex:121# TODO: Remove the following once the feature stabilizes:122if _skip_bad_config(project_table, orig_setuptools_table, dist):123return {}124# TODO: After the previous statement is removed the try/except can be replaced125# by the _ignore_errors context manager.126if ignore_option_errors:127_logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")128else:129raise # re-raise exception130131if expand:132root_dir = os.path.dirname(filepath)133return expand_configuration(asdict, root_dir, ignore_option_errors, dist)134135return asdict136137138def _skip_bad_config(139project_cfg: dict, setuptools_cfg: dict, dist: Optional["Distribution"]140) -> bool:141"""Be temporarily forgiving with invalid ``pyproject.toml``"""142# See pypa/setuptools#3199 and pypa/cibuildwheel#1064143144if dist is None or (145dist.metadata.name is None146and dist.metadata.version is None147and dist.install_requires is None148):149# It seems that the build is not getting any configuration from other places150return False151152if setuptools_cfg:153# If `[tool.setuptools]` is set, then `pyproject.toml` config is intentional154return False155156given_config = set(project_cfg.keys())157popular_subset = {"name", "version", "python_requires", "requires-python"}158if given_config <= popular_subset:159# It seems that the docs in cibuildtool has been inadvertently encouraging users160# to create `pyproject.toml` files that are not compliant with the standards.161# Let's be forgiving for the time being.162warnings.warn(_InvalidFile.message(), _InvalidFile, stacklevel=2)163return True164165return False166167168def expand_configuration(169config: dict,170root_dir: Optional[_Path] = None,171ignore_option_errors: bool = False,172dist: Optional["Distribution"] = None,173) -> dict:174"""Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)175find their final values.176177:param dict config: Dict containing the configuration for the distribution178:param str root_dir: Top-level directory for the distribution/project179(the same directory where ``pyproject.toml`` is place)180:param bool ignore_option_errors: see :func:`read_configuration`181:param Distribution|None: Distribution object to which the configuration refers.182If not given a dummy object will be created and discarded after the183configuration is read. Used in the case a dynamic configuration184(e.g. ``attr`` or ``cmdclass``).185186:rtype: dict187"""188return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()189190191class _ConfigExpander:192def __init__(193self,194config: dict,195root_dir: Optional[_Path] = None,196ignore_option_errors: bool = False,197dist: Optional["Distribution"] = None,198):199self.config = config200self.root_dir = root_dir or os.getcwd()201self.project_cfg = config.get("project", {})202self.dynamic = self.project_cfg.get("dynamic", [])203self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})204self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})205self.ignore_option_errors = ignore_option_errors206self._dist = dist207208def _ensure_dist(self) -> "Distribution":209from setuptools.dist import Distribution210211attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}212return self._dist or Distribution(attrs)213214def _process_field(self, container: dict, field: str, fn: Callable):215if field in container:216with _ignore_errors(self.ignore_option_errors):217container[field] = fn(container[field])218219def _canonic_package_data(self, field="package-data"):220package_data = self.setuptools_cfg.get(field, {})221return _expand.canonic_package_data(package_data)222223def expand(self):224self._expand_packages()225self._canonic_package_data()226self._canonic_package_data("exclude-package-data")227228# A distribution object is required for discovering the correct package_dir229dist = self._ensure_dist()230231with _EnsurePackagesDiscovered(dist, self.setuptools_cfg) as ensure_discovered:232package_dir = ensure_discovered.package_dir233self._expand_data_files()234self._expand_cmdclass(package_dir)235self._expand_all_dynamic(dist, package_dir)236237return self.config238239def _expand_packages(self):240packages = self.setuptools_cfg.get("packages")241if packages is None or isinstance(packages, (list, tuple)):242return243244find = packages.get("find")245if isinstance(find, dict):246find["root_dir"] = self.root_dir247find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})248with _ignore_errors(self.ignore_option_errors):249self.setuptools_cfg["packages"] = _expand.find_packages(**find)250251def _expand_data_files(self):252data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)253self._process_field(self.setuptools_cfg, "data-files", data_files)254255def _expand_cmdclass(self, package_dir: Mapping[str, str]):256root_dir = self.root_dir257cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)258self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)259260def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, str]):261special = ( # need special handling262"version",263"readme",264"entry-points",265"scripts",266"gui-scripts",267"classifiers",268)269# `_obtain` functions are assumed to raise appropriate exceptions/warnings.270obtained_dynamic = {271field: self._obtain(dist, field, package_dir)272for field in self.dynamic273if field not in special274}275obtained_dynamic.update(276self._obtain_entry_points(dist, package_dir) or {},277version=self._obtain_version(dist, package_dir),278readme=self._obtain_readme(dist),279classifiers=self._obtain_classifiers(dist),280)281# `None` indicates there is nothing in `tool.setuptools.dynamic` but the value282# might have already been set by setup.py/extensions, so avoid overwriting.283updates = {k: v for k, v in obtained_dynamic.items() if v is not None}284self.project_cfg.update(updates)285286def _ensure_previously_set(self, dist: "Distribution", field: str):287previous = _PREVIOUSLY_DEFINED[field](dist)288if previous is None and not self.ignore_option_errors:289msg = (290f"No configuration found for dynamic {field!r}.\n"291"Some dynamic fields need to be specified via `tool.setuptools.dynamic`"292"\nothers must be specified via the equivalent attribute in `setup.py`."293)294raise OptionError(msg)295296def _obtain(self, dist: "Distribution", field: str, package_dir: Mapping[str, str]):297if field in self.dynamic_cfg:298directive = self.dynamic_cfg[field]299with _ignore_errors(self.ignore_option_errors):300root_dir = self.root_dir301if "file" in directive:302return _expand.read_files(directive["file"], root_dir)303if "attr" in directive:304return _expand.read_attr(directive["attr"], package_dir, root_dir)305msg = f"invalid `tool.setuptools.dynamic.{field}`: {directive!r}"306raise ValueError(msg)307return None308self._ensure_previously_set(dist, field)309return None310311def _obtain_version(self, dist: "Distribution", package_dir: Mapping[str, str]):312# Since plugins can set version, let's silently skip if it cannot be obtained313if "version" in self.dynamic and "version" in self.dynamic_cfg:314return _expand.version(self._obtain(dist, "version", package_dir))315return None316317def _obtain_readme(self, dist: "Distribution") -> Optional[Dict[str, str]]:318if "readme" not in self.dynamic:319return None320321dynamic_cfg = self.dynamic_cfg322if "readme" in dynamic_cfg:323return {324"text": self._obtain(dist, "readme", {}),325"content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),326}327328self._ensure_previously_set(dist, "readme")329return None330331def _obtain_entry_points(332self, dist: "Distribution", package_dir: Mapping[str, str]333) -> Optional[Dict[str, dict]]:334fields = ("entry-points", "scripts", "gui-scripts")335if not any(field in self.dynamic for field in fields):336return None337338text = self._obtain(dist, "entry-points", package_dir)339if text is None:340return None341342groups = _expand.entry_points(text)343expanded = {"entry-points": groups}344345def _set_scripts(field: str, group: str):346if group in groups:347value = groups.pop(group)348if field not in self.dynamic:349msg = _WouldIgnoreField.message(field, value)350warnings.warn(msg, _WouldIgnoreField)351# TODO: Don't set field when support for pyproject.toml stabilizes352# instead raise an error as specified in PEP 621353expanded[field] = value354355_set_scripts("scripts", "console_scripts")356_set_scripts("gui-scripts", "gui_scripts")357358return expanded359360def _obtain_classifiers(self, dist: "Distribution"):361if "classifiers" in self.dynamic:362value = self._obtain(dist, "classifiers", {})363if value:364return value.splitlines()365return None366367368@contextmanager369def _ignore_errors(ignore_option_errors: bool):370if not ignore_option_errors:371yield372return373374try:375yield376except Exception as ex:377_logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")378379380class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):381def __init__(self, distribution: "Distribution", setuptools_cfg: dict):382super().__init__(distribution)383self._setuptools_cfg = setuptools_cfg384385def __enter__(self):386"""When entering the context, the values of ``packages``, ``py_modules`` and387``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.388"""389dist, cfg = self._dist, self._setuptools_cfg390package_dir: Dict[str, str] = cfg.setdefault("package-dir", {})391package_dir.update(dist.package_dir or {})392dist.package_dir = package_dir # needs to be the same object393394dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour395396# Set `py_modules` and `packages` in dist to short-circuit auto-discovery,397# but avoid overwriting empty lists purposefully set by users.398if dist.py_modules is None:399dist.py_modules = cfg.get("py-modules")400if dist.packages is None:401dist.packages = cfg.get("packages")402403return super().__enter__()404405def __exit__(self, exc_type, exc_value, traceback):406"""When exiting the context, if values of ``packages``, ``py_modules`` and407``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.408"""409# If anything was discovered set them back, so they count in the final config.410self._setuptools_cfg.setdefault("packages", self._dist.packages)411self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)412return super().__exit__(exc_type, exc_value, traceback)413414415class _ExperimentalProjectMetadata(UserWarning):416"""Explicitly inform users that `pyproject.toml` configuration is experimental"""417418419class _InvalidFile(UserWarning):420"""The given `pyproject.toml` file is invalid and would be ignored.421!!\n\n422############################423# Invalid `pyproject.toml` #424############################425426Any configurations in `pyproject.toml` will be ignored.427Please note that future releases of setuptools will halt the build process428if an invalid file is given.429430To prevent setuptools from considering `pyproject.toml` please431DO NOT include the `[project]` or `[tool.setuptools]` tables in your file.432\n\n!!433"""434435@classmethod436def message(cls):437from inspect import cleandoc438return cleandoc(cls.__doc__)439440441