codekingpro/portable-devtools
114k
1"""2Load setuptools configuration from ``pyproject.toml`` files.3 4**PRIVATE MODULE**: API reserved for setuptools internal usage only.5 6To read project metadata, consider using7``build.util.project_wheel_metadata`` (https://pypi.org/project/build/).8For simple scenarios, you can also try parsing the file directly9with the help of ``tomllib`` or ``tomli``.10"""11 12import logging13import os14from contextlib import contextmanager15from functools import partial16from typing import TYPE_CHECKING, Callable, Dict, Mapping, Optional, Set17 18from .._path import StrPath19from ..errors import FileError, InvalidConfigError20from ..warnings import SetuptoolsWarning21from . import expand as _expand22from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _MissingDynamic23from ._apply_pyprojecttoml import apply as _apply24 25if TYPE_CHECKING:26 from setuptools.dist import Distribution # noqa27 from typing_extensions import Self28 29_logger = logging.getLogger(__name__)30 31 32def load_file(filepath: StrPath) -> dict:33 from ..compat.py310 import tomllib34 35 with open(filepath, "rb") as file:36 return tomllib.load(file)37 38 39def validate(config: dict, filepath: StrPath) -> bool:40 from . import _validate_pyproject as validator41 42 trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")43 if hasattr(trove_classifier, "_disable_download"):44 # Improve reproducibility by default. See issue 31 for validate-pyproject.45 trove_classifier._disable_download() # type: ignore46 47 try:48 return validator.validate(config)49 except validator.ValidationError as ex:50 summary = f"configuration error: {ex.summary}"51 if ex.name.strip("`") != "project":52 # Probably it is just a field missing/misnamed, not worthy the verbosity...53 _logger.debug(summary)54 _logger.debug(ex.details)55 56 error = f"invalid pyproject.toml config: {ex.name}."57 raise ValueError(f"{error}\n{summary}") from None58 59 60def apply_configuration(61 dist: "Distribution",62 filepath: StrPath,63 ignore_option_errors=False,64) -> "Distribution":65 """Apply the configuration from a ``pyproject.toml`` file into an existing66 distribution object.67 """68 config = read_configuration(filepath, True, ignore_option_errors, dist)69 return _apply(dist, config, filepath)70 71 72def read_configuration(73 filepath: StrPath,74 expand=True,75 ignore_option_errors=False,76 dist: Optional["Distribution"] = None,77):78 """Read given configuration file and returns options from it as a dict.79 80 :param str|unicode filepath: Path to configuration file in the ``pyproject.toml``81 format.82 83 :param bool expand: Whether to expand directives and other computed values84 (i.e. post-process the given configuration)85 86 :param bool ignore_option_errors: Whether to silently ignore87 options, values of which could not be resolved (e.g. due to exceptions88 in directives such as file:, attr:, etc.).89 If False exceptions are propagated as expected.90 91 :param Distribution|None: Distribution object to which the configuration refers.92 If not given a dummy object will be created and discarded after the93 configuration is read. This is used for auto-discovery of packages and in the94 case a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.95 When ``expand=False`` this object is simply ignored.96 97 :rtype: dict98 """99 filepath = os.path.abspath(filepath)100 101 if not os.path.isfile(filepath):102 raise FileError(f"Configuration file {filepath!r} does not exist.")103 104 asdict = load_file(filepath) or {}105 project_table = asdict.get("project", {})106 tool_table = asdict.get("tool", {})107 setuptools_table = tool_table.get("setuptools", {})108 if not asdict or not (project_table or setuptools_table):109 return {} # User is not using pyproject to configure setuptools110 111 if "distutils" in tool_table:112 _ExperimentalConfiguration.emit(subject="[tool.distutils]")113 114 # There is an overall sense in the community that making include_package_data=True115 # the default would be an improvement.116 # `ini2toml` backfills include_package_data=False when nothing is explicitly given,117 # therefore setting a default here is backwards compatible.118 if dist and getattr(dist, "include_package_data", None) is not None:119 setuptools_table.setdefault("include-package-data", dist.include_package_data)120 else:121 setuptools_table.setdefault("include-package-data", True)122 # Persist changes:123 asdict["tool"] = tool_table124 tool_table["setuptools"] = setuptools_table125 126 with _ignore_errors(ignore_option_errors):127 # Don't complain about unrelated errors (e.g. tools not using the "tool" table)128 subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}129 validate(subset, filepath)130 131 if expand:132 root_dir = os.path.dirname(filepath)133 return expand_configuration(asdict, root_dir, ignore_option_errors, dist)134 135 return asdict136 137 138def expand_configuration(139 config: dict,140 root_dir: Optional[StrPath] = None,141 ignore_option_errors: bool = False,142 dist: Optional["Distribution"] = None,143) -> dict:144 """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)145 find their final values.146 147 :param dict config: Dict containing the configuration for the distribution148 :param str root_dir: Top-level directory for the distribution/project149 (the same directory where ``pyproject.toml`` is place)150 :param bool ignore_option_errors: see :func:`read_configuration`151 :param Distribution|None: Distribution object to which the configuration refers.152 If not given a dummy object will be created and discarded after the153 configuration is read. Used in the case a dynamic configuration154 (e.g. ``attr`` or ``cmdclass``).155 156 :rtype: dict157 """158 return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()159 160 161class _ConfigExpander:162 def __init__(163 self,164 config: dict,165 root_dir: Optional[StrPath] = None,166 ignore_option_errors: bool = False,167 dist: Optional["Distribution"] = None,168 ):169 self.config = config170 self.root_dir = root_dir or os.getcwd()171 self.project_cfg = config.get("project", {})172 self.dynamic = self.project_cfg.get("dynamic", [])173 self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})174 self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})175 self.ignore_option_errors = ignore_option_errors176 self._dist = dist177 self._referenced_files: Set[str] = set()178 179 def _ensure_dist(self) -> "Distribution":180 from setuptools.dist import Distribution181 182 attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}183 return self._dist or Distribution(attrs)184 185 def _process_field(self, container: dict, field: str, fn: Callable):186 if field in container:187 with _ignore_errors(self.ignore_option_errors):188 container[field] = fn(container[field])189 190 def _canonic_package_data(self, field="package-data"):191 package_data = self.setuptools_cfg.get(field, {})192 return _expand.canonic_package_data(package_data)193 194 def expand(self):195 self._expand_packages()196 self._canonic_package_data()197 self._canonic_package_data("exclude-package-data")198 199 # A distribution object is required for discovering the correct package_dir200 dist = self._ensure_dist()201 ctx = _EnsurePackagesDiscovered(dist, self.project_cfg, self.setuptools_cfg)202 with ctx as ensure_discovered:203 package_dir = ensure_discovered.package_dir204 self._expand_data_files()205 self._expand_cmdclass(package_dir)206 self._expand_all_dynamic(dist, package_dir)207 208 dist._referenced_files.update(self._referenced_files)209 return self.config210 211 def _expand_packages(self):212 packages = self.setuptools_cfg.get("packages")213 if packages is None or isinstance(packages, (list, tuple)):214 return215 216 find = packages.get("find")217 if isinstance(find, dict):218 find["root_dir"] = self.root_dir219 find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})220 with _ignore_errors(self.ignore_option_errors):221 self.setuptools_cfg["packages"] = _expand.find_packages(**find)222 223 def _expand_data_files(self):224 data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)225 self._process_field(self.setuptools_cfg, "data-files", data_files)226 227 def _expand_cmdclass(self, package_dir: Mapping[str, str]):228 root_dir = self.root_dir229 cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)230 self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)231 232 def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, str]):233 special = ( # need special handling234 "version",235 "readme",236 "entry-points",237 "scripts",238 "gui-scripts",239 "classifiers",240 "dependencies",241 "optional-dependencies",242 )243 # `_obtain` functions are assumed to raise appropriate exceptions/warnings.244 obtained_dynamic = {245 field: self._obtain(dist, field, package_dir)246 for field in self.dynamic247 if field not in special248 }249 obtained_dynamic.update(250 self._obtain_entry_points(dist, package_dir) or {},251 version=self._obtain_version(dist, package_dir),252 readme=self._obtain_readme(dist),253 classifiers=self._obtain_classifiers(dist),254 dependencies=self._obtain_dependencies(dist),255 optional_dependencies=self._obtain_optional_dependencies(dist),256 )257 # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value258 # might have already been set by setup.py/extensions, so avoid overwriting.259 updates = {k: v for k, v in obtained_dynamic.items() if v is not None}260 self.project_cfg.update(updates)261 262 def _ensure_previously_set(self, dist: "Distribution", field: str):263 previous = _PREVIOUSLY_DEFINED[field](dist)264 if previous is None and not self.ignore_option_errors:265 msg = (266 f"No configuration found for dynamic {field!r}.\n"267 "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"268 "\nothers must be specified via the equivalent attribute in `setup.py`."269 )270 raise InvalidConfigError(msg)271 272 def _expand_directive(273 self, specifier: str, directive, package_dir: Mapping[str, str]274 ):275 from setuptools.extern.more_itertools import always_iterable276 277 with _ignore_errors(self.ignore_option_errors):278 root_dir = self.root_dir279 if "file" in directive:280 self._referenced_files.update(always_iterable(directive["file"]))281 return _expand.read_files(directive["file"], root_dir)282 if "attr" in directive:283 return _expand.read_attr(directive["attr"], package_dir, root_dir)284 raise ValueError(f"invalid `{specifier}`: {directive!r}")285 return None286 287 def _obtain(self, dist: "Distribution", field: str, package_dir: Mapping[str, str]):288 if field in self.dynamic_cfg:289 return self._expand_directive(290 f"tool.setuptools.dynamic.{field}",291 self.dynamic_cfg[field],292 package_dir,293 )294 self._ensure_previously_set(dist, field)295 return None296 297 def _obtain_version(self, dist: "Distribution", package_dir: Mapping[str, str]):298 # Since plugins can set version, let's silently skip if it cannot be obtained299 if "version" in self.dynamic and "version" in self.dynamic_cfg:300 return _expand.version(self._obtain(dist, "version", package_dir))301 return None302 303 def _obtain_readme(self, dist: "Distribution") -> Optional[Dict[str, str]]:304 if "readme" not in self.dynamic:305 return None306 307 dynamic_cfg = self.dynamic_cfg308 if "readme" in dynamic_cfg:309 return {310 "text": self._obtain(dist, "readme", {}),311 "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),312 }313 314 self._ensure_previously_set(dist, "readme")315 return None316 317 def _obtain_entry_points(318 self, dist: "Distribution", package_dir: Mapping[str, str]319 ) -> Optional[Dict[str, dict]]:320 fields = ("entry-points", "scripts", "gui-scripts")321 if not any(field in self.dynamic for field in fields):322 return None323 324 text = self._obtain(dist, "entry-points", package_dir)325 if text is None:326 return None327 328 groups = _expand.entry_points(text)329 expanded = {"entry-points": groups}330 331 def _set_scripts(field: str, group: str):332 if group in groups:333 value = groups.pop(group)334 if field not in self.dynamic:335 raise InvalidConfigError(_MissingDynamic.details(field, value))336 expanded[field] = value337 338 _set_scripts("scripts", "console_scripts")339 _set_scripts("gui-scripts", "gui_scripts")340 341 return expanded342 343 def _obtain_classifiers(self, dist: "Distribution"):344 if "classifiers" in self.dynamic:345 value = self._obtain(dist, "classifiers", {})346 if value:347 return value.splitlines()348 return None349 350 def _obtain_dependencies(self, dist: "Distribution"):351 if "dependencies" in self.dynamic:352 value = self._obtain(dist, "dependencies", {})353 if value:354 return _parse_requirements_list(value)355 return None356 357 def _obtain_optional_dependencies(self, dist: "Distribution"):358 if "optional-dependencies" not in self.dynamic:359 return None360 if "optional-dependencies" in self.dynamic_cfg:361 optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]362 assert isinstance(optional_dependencies_map, dict)363 return {364 group: _parse_requirements_list(365 self._expand_directive(366 f"tool.setuptools.dynamic.optional-dependencies.{group}",367 directive,368 {},369 )370 )371 for group, directive in optional_dependencies_map.items()372 }373 self._ensure_previously_set(dist, "optional-dependencies")374 return None375 376 377def _parse_requirements_list(value):378 return [379 line380 for line in value.splitlines()381 if line.strip() and not line.strip().startswith("#")382 ]383 384 385@contextmanager386def _ignore_errors(ignore_option_errors: bool):387 if not ignore_option_errors:388 yield389 return390 391 try:392 yield393 except Exception as ex:394 _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")395 396 397class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):398 def __init__(399 self, distribution: "Distribution", project_cfg: dict, setuptools_cfg: dict400 ):401 super().__init__(distribution)402 self._project_cfg = project_cfg403 self._setuptools_cfg = setuptools_cfg404 405 def __enter__(self) -> "Self":406 """When entering the context, the values of ``packages``, ``py_modules`` and407 ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.408 """409 dist, cfg = self._dist, self._setuptools_cfg410 package_dir: Dict[str, str] = cfg.setdefault("package-dir", {})411 package_dir.update(dist.package_dir or {})412 dist.package_dir = package_dir # needs to be the same object413 414 dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour415 416 # Set `name`, `py_modules` and `packages` in dist to short-circuit417 # auto-discovery, but avoid overwriting empty lists purposefully set by users.418 if dist.metadata.name is None:419 dist.metadata.name = self._project_cfg.get("name")420 if dist.py_modules is None:421 dist.py_modules = cfg.get("py-modules")422 if dist.packages is None:423 dist.packages = cfg.get("packages")424 425 return super().__enter__()426 427 def __exit__(self, exc_type, exc_value, traceback):428 """When exiting the context, if values of ``packages``, ``py_modules`` and429 ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.430 """431 # If anything was discovered set them back, so they count in the final config.432 self._setuptools_cfg.setdefault("packages", self._dist.packages)433 self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)434 return super().__exit__(exc_type, exc_value, traceback)435 436 437class _ExperimentalConfiguration(SetuptoolsWarning):438 _SUMMARY = (439 "`{subject}` in `pyproject.toml` is still *experimental* "440 "and likely to change in future releases."441 )442 