123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- """
- Load setuptools configuration from ``pyproject.toml`` files.
- **PRIVATE MODULE**: API reserved for setuptools internal usage only.
- """
- import logging
- import os
- import warnings
- from contextlib import contextmanager
- from functools import partial
- from typing import TYPE_CHECKING, Callable, Dict, Optional, Mapping, Union
- from setuptools.errors import FileError, OptionError
- from . import expand as _expand
- from ._apply_pyprojecttoml import apply as _apply
- from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _WouldIgnoreField
- if TYPE_CHECKING:
- from setuptools.dist import Distribution # noqa
- _Path = Union[str, os.PathLike]
- _logger = logging.getLogger(__name__)
- def load_file(filepath: _Path) -> dict:
- from setuptools.extern import tomli # type: ignore
- with open(filepath, "rb") as file:
- return tomli.load(file)
- def validate(config: dict, filepath: _Path) -> bool:
- from . import _validate_pyproject as validator
- trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")
- if hasattr(trove_classifier, "_disable_download"):
- # Improve reproducibility by default. See issue 31 for validate-pyproject.
- trove_classifier._disable_download() # type: ignore
- try:
- return validator.validate(config)
- except validator.ValidationError as ex:
- summary = f"configuration error: {ex.summary}"
- if ex.name.strip("`") != "project":
- # Probably it is just a field missing/misnamed, not worthy the verbosity...
- _logger.debug(summary)
- _logger.debug(ex.details)
- error = f"invalid pyproject.toml config: {ex.name}."
- raise ValueError(f"{error}\n{summary}") from None
- def apply_configuration(
- dist: "Distribution",
- filepath: _Path,
- ignore_option_errors=False,
- ) -> "Distribution":
- """Apply the configuration from a ``pyproject.toml`` file into an existing
- distribution object.
- """
- config = read_configuration(filepath, True, ignore_option_errors, dist)
- return _apply(dist, config, filepath)
- def read_configuration(
- filepath: _Path,
- expand=True,
- ignore_option_errors=False,
- dist: Optional["Distribution"] = None,
- ):
- """Read given configuration file and returns options from it as a dict.
- :param str|unicode filepath: Path to configuration file in the ``pyproject.toml``
- format.
- :param bool expand: Whether to expand directives and other computed values
- (i.e. post-process the given configuration)
- :param bool ignore_option_errors: Whether to silently ignore
- options, values of which could not be resolved (e.g. due to exceptions
- in directives such as file:, attr:, etc.).
- If False exceptions are propagated as expected.
- :param Distribution|None: Distribution object to which the configuration refers.
- If not given a dummy object will be created and discarded after the
- configuration is read. This is used for auto-discovery of packages in the case
- a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.
- When ``expand=False`` this object is simply ignored.
- :rtype: dict
- """
- filepath = os.path.abspath(filepath)
- if not os.path.isfile(filepath):
- raise FileError(f"Configuration file {filepath!r} does not exist.")
- asdict = load_file(filepath) or {}
- project_table = asdict.get("project", {})
- tool_table = asdict.get("tool", {})
- setuptools_table = tool_table.get("setuptools", {})
- if not asdict or not (project_table or setuptools_table):
- return {} # User is not using pyproject to configure setuptools
- if setuptools_table:
- # TODO: Remove the following once the feature stabilizes:
- msg = "Support for `[tool.setuptools]` in `pyproject.toml` is still *beta*."
- warnings.warn(msg, _BetaConfiguration)
- # There is an overall sense in the community that making include_package_data=True
- # the default would be an improvement.
- # `ini2toml` backfills include_package_data=False when nothing is explicitly given,
- # therefore setting a default here is backwards compatible.
- orig_setuptools_table = setuptools_table.copy()
- if dist and getattr(dist, "include_package_data") is not None:
- setuptools_table.setdefault("include-package-data", dist.include_package_data)
- else:
- setuptools_table.setdefault("include-package-data", True)
- # Persist changes:
- asdict["tool"] = tool_table
- tool_table["setuptools"] = setuptools_table
- try:
- # Don't complain about unrelated errors (e.g. tools not using the "tool" table)
- subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}
- validate(subset, filepath)
- except Exception as ex:
- # TODO: Remove the following once the feature stabilizes:
- if _skip_bad_config(project_table, orig_setuptools_table, dist):
- return {}
- # TODO: After the previous statement is removed the try/except can be replaced
- # by the _ignore_errors context manager.
- if ignore_option_errors:
- _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
- else:
- raise # re-raise exception
- if expand:
- root_dir = os.path.dirname(filepath)
- return expand_configuration(asdict, root_dir, ignore_option_errors, dist)
- return asdict
- def _skip_bad_config(
- project_cfg: dict, setuptools_cfg: dict, dist: Optional["Distribution"]
- ) -> bool:
- """Be temporarily forgiving with invalid ``pyproject.toml``"""
- # See pypa/setuptools#3199 and pypa/cibuildwheel#1064
- if dist is None or (
- dist.metadata.name is None
- and dist.metadata.version is None
- and dist.install_requires is None
- ):
- # It seems that the build is not getting any configuration from other places
- return False
- if setuptools_cfg:
- # If `[tool.setuptools]` is set, then `pyproject.toml` config is intentional
- return False
- given_config = set(project_cfg.keys())
- popular_subset = {"name", "version", "python_requires", "requires-python"}
- if given_config <= popular_subset:
- # It seems that the docs in cibuildtool has been inadvertently encouraging users
- # to create `pyproject.toml` files that are not compliant with the standards.
- # Let's be forgiving for the time being.
- warnings.warn(_InvalidFile.message(), _InvalidFile, stacklevel=2)
- return True
- return False
- def expand_configuration(
- config: dict,
- root_dir: Optional[_Path] = None,
- ignore_option_errors: bool = False,
- dist: Optional["Distribution"] = None,
- ) -> dict:
- """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)
- find their final values.
- :param dict config: Dict containing the configuration for the distribution
- :param str root_dir: Top-level directory for the distribution/project
- (the same directory where ``pyproject.toml`` is place)
- :param bool ignore_option_errors: see :func:`read_configuration`
- :param Distribution|None: Distribution object to which the configuration refers.
- If not given a dummy object will be created and discarded after the
- configuration is read. Used in the case a dynamic configuration
- (e.g. ``attr`` or ``cmdclass``).
- :rtype: dict
- """
- return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()
- class _ConfigExpander:
- def __init__(
- self,
- config: dict,
- root_dir: Optional[_Path] = None,
- ignore_option_errors: bool = False,
- dist: Optional["Distribution"] = None,
- ):
- self.config = config
- self.root_dir = root_dir or os.getcwd()
- self.project_cfg = config.get("project", {})
- self.dynamic = self.project_cfg.get("dynamic", [])
- self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})
- self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})
- self.ignore_option_errors = ignore_option_errors
- self._dist = dist
- def _ensure_dist(self) -> "Distribution":
- from setuptools.dist import Distribution
- attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}
- return self._dist or Distribution(attrs)
- def _process_field(self, container: dict, field: str, fn: Callable):
- if field in container:
- with _ignore_errors(self.ignore_option_errors):
- container[field] = fn(container[field])
- def _canonic_package_data(self, field="package-data"):
- package_data = self.setuptools_cfg.get(field, {})
- return _expand.canonic_package_data(package_data)
- def expand(self):
- self._expand_packages()
- self._canonic_package_data()
- self._canonic_package_data("exclude-package-data")
- # A distribution object is required for discovering the correct package_dir
- dist = self._ensure_dist()
- ctx = _EnsurePackagesDiscovered(dist, self.project_cfg, self.setuptools_cfg)
- with ctx as ensure_discovered:
- package_dir = ensure_discovered.package_dir
- self._expand_data_files()
- self._expand_cmdclass(package_dir)
- self._expand_all_dynamic(dist, package_dir)
- return self.config
- def _expand_packages(self):
- packages = self.setuptools_cfg.get("packages")
- if packages is None or isinstance(packages, (list, tuple)):
- return
- find = packages.get("find")
- if isinstance(find, dict):
- find["root_dir"] = self.root_dir
- find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})
- with _ignore_errors(self.ignore_option_errors):
- self.setuptools_cfg["packages"] = _expand.find_packages(**find)
- def _expand_data_files(self):
- data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)
- self._process_field(self.setuptools_cfg, "data-files", data_files)
- def _expand_cmdclass(self, package_dir: Mapping[str, str]):
- root_dir = self.root_dir
- cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)
- self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)
- def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, str]):
- special = ( # need special handling
- "version",
- "readme",
- "entry-points",
- "scripts",
- "gui-scripts",
- "classifiers",
- "dependencies",
- "optional-dependencies",
- )
- # `_obtain` functions are assumed to raise appropriate exceptions/warnings.
- obtained_dynamic = {
- field: self._obtain(dist, field, package_dir)
- for field in self.dynamic
- if field not in special
- }
- obtained_dynamic.update(
- self._obtain_entry_points(dist, package_dir) or {},
- version=self._obtain_version(dist, package_dir),
- readme=self._obtain_readme(dist),
- classifiers=self._obtain_classifiers(dist),
- dependencies=self._obtain_dependencies(dist),
- optional_dependencies=self._obtain_optional_dependencies(dist),
- )
- # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
- # might have already been set by setup.py/extensions, so avoid overwriting.
- updates = {k: v for k, v in obtained_dynamic.items() if v is not None}
- self.project_cfg.update(updates)
- def _ensure_previously_set(self, dist: "Distribution", field: str):
- previous = _PREVIOUSLY_DEFINED[field](dist)
- if previous is None and not self.ignore_option_errors:
- msg = (
- f"No configuration found for dynamic {field!r}.\n"
- "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"
- "\nothers must be specified via the equivalent attribute in `setup.py`."
- )
- raise OptionError(msg)
- def _expand_directive(
- self, specifier: str, directive, package_dir: Mapping[str, str]
- ):
- with _ignore_errors(self.ignore_option_errors):
- root_dir = self.root_dir
- if "file" in directive:
- return _expand.read_files(directive["file"], root_dir)
- if "attr" in directive:
- return _expand.read_attr(directive["attr"], package_dir, root_dir)
- raise ValueError(f"invalid `{specifier}`: {directive!r}")
- return None
- def _obtain(self, dist: "Distribution", field: str, package_dir: Mapping[str, str]):
- if field in self.dynamic_cfg:
- return self._expand_directive(
- f"tool.setuptools.dynamic.{field}",
- self.dynamic_cfg[field],
- package_dir,
- )
- self._ensure_previously_set(dist, field)
- return None
- def _obtain_version(self, dist: "Distribution", package_dir: Mapping[str, str]):
- # Since plugins can set version, let's silently skip if it cannot be obtained
- if "version" in self.dynamic and "version" in self.dynamic_cfg:
- return _expand.version(self._obtain(dist, "version", package_dir))
- return None
- def _obtain_readme(self, dist: "Distribution") -> Optional[Dict[str, str]]:
- if "readme" not in self.dynamic:
- return None
- dynamic_cfg = self.dynamic_cfg
- if "readme" in dynamic_cfg:
- return {
- "text": self._obtain(dist, "readme", {}),
- "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
- }
- self._ensure_previously_set(dist, "readme")
- return None
- def _obtain_entry_points(
- self, dist: "Distribution", package_dir: Mapping[str, str]
- ) -> Optional[Dict[str, dict]]:
- fields = ("entry-points", "scripts", "gui-scripts")
- if not any(field in self.dynamic for field in fields):
- return None
- text = self._obtain(dist, "entry-points", package_dir)
- if text is None:
- return None
- groups = _expand.entry_points(text)
- expanded = {"entry-points": groups}
- def _set_scripts(field: str, group: str):
- if group in groups:
- value = groups.pop(group)
- if field not in self.dynamic:
- msg = _WouldIgnoreField.message(field, value)
- warnings.warn(msg, _WouldIgnoreField)
- # TODO: Don't set field when support for pyproject.toml stabilizes
- # instead raise an error as specified in PEP 621
- expanded[field] = value
- _set_scripts("scripts", "console_scripts")
- _set_scripts("gui-scripts", "gui_scripts")
- return expanded
- def _obtain_classifiers(self, dist: "Distribution"):
- if "classifiers" in self.dynamic:
- value = self._obtain(dist, "classifiers", {})
- if value:
- return value.splitlines()
- return None
- def _obtain_dependencies(self, dist: "Distribution"):
- if "dependencies" in self.dynamic:
- value = self._obtain(dist, "dependencies", {})
- if value:
- return _parse_requirements_list(value)
- return None
- def _obtain_optional_dependencies(self, dist: "Distribution"):
- if "optional-dependencies" not in self.dynamic:
- return None
- if "optional-dependencies" in self.dynamic_cfg:
- optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]
- assert isinstance(optional_dependencies_map, dict)
- return {
- group: _parse_requirements_list(self._expand_directive(
- f"tool.setuptools.dynamic.optional-dependencies.{group}",
- directive,
- {},
- ))
- for group, directive in optional_dependencies_map.items()
- }
- self._ensure_previously_set(dist, "optional-dependencies")
- return None
- def _parse_requirements_list(value):
- return [
- line
- for line in value.splitlines()
- if line.strip() and not line.strip().startswith("#")
- ]
- @contextmanager
- def _ignore_errors(ignore_option_errors: bool):
- if not ignore_option_errors:
- yield
- return
- try:
- yield
- except Exception as ex:
- _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
- class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):
- def __init__(
- self, distribution: "Distribution", project_cfg: dict, setuptools_cfg: dict
- ):
- super().__init__(distribution)
- self._project_cfg = project_cfg
- self._setuptools_cfg = setuptools_cfg
- def __enter__(self):
- """When entering the context, the values of ``packages``, ``py_modules`` and
- ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.
- """
- dist, cfg = self._dist, self._setuptools_cfg
- package_dir: Dict[str, str] = cfg.setdefault("package-dir", {})
- package_dir.update(dist.package_dir or {})
- dist.package_dir = package_dir # needs to be the same object
- dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour
- # Set `name`, `py_modules` and `packages` in dist to short-circuit
- # auto-discovery, but avoid overwriting empty lists purposefully set by users.
- if dist.metadata.name is None:
- dist.metadata.name = self._project_cfg.get("name")
- if dist.py_modules is None:
- dist.py_modules = cfg.get("py-modules")
- if dist.packages is None:
- dist.packages = cfg.get("packages")
- return super().__enter__()
- def __exit__(self, exc_type, exc_value, traceback):
- """When exiting the context, if values of ``packages``, ``py_modules`` and
- ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.
- """
- # If anything was discovered set them back, so they count in the final config.
- self._setuptools_cfg.setdefault("packages", self._dist.packages)
- self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)
- return super().__exit__(exc_type, exc_value, traceback)
- class _BetaConfiguration(UserWarning):
- """Explicitly inform users that some `pyproject.toml` configuration is *beta*"""
- class _InvalidFile(UserWarning):
- """The given `pyproject.toml` file is invalid and would be ignored.
- !!\n\n
- ############################
- # Invalid `pyproject.toml` #
- ############################
- Any configurations in `pyproject.toml` will be ignored.
- Please note that future releases of setuptools will halt the build process
- if an invalid file is given.
- To prevent setuptools from considering `pyproject.toml` please
- DO NOT include the `[project]` or `[tool.setuptools]` tables in your file.
- \n\n!!
- """
- @classmethod
- def message(cls):
- from inspect import cleandoc
- return cleandoc(cls.__doc__)
|