1"""Load setuptools configuration from ``pyproject.toml`` files""" 2import logging 3import os 4import warnings 5from contextlib import contextmanager 6from functools import partial 7from typing import TYPE_CHECKING, Callable, Dict, Optional, Mapping, Union 8 9from setuptools.errors import FileError, OptionError 10 11from . import expand as _expand 12from ._apply_pyprojecttoml import apply 13 14if TYPE_CHECKING: 15 from setuptools.dist import Distribution # noqa 16 17_Path = Union[str, os.PathLike] 18_logger = logging.getLogger(__name__) 19 20 21def load_file(filepath: _Path) -> dict: 22 from setuptools.extern import tomli # type: ignore 23 24 with open(filepath, "rb") as file: 25 return tomli.load(file) 26 27 28def validate(config: dict, filepath: _Path): 29 from setuptools.extern._validate_pyproject import validate as _validate 30 31 try: 32 return _validate(config) 33 except Exception as ex: 34 if ex.__class__.__name__ != "ValidationError": 35 # Workaround for the fact that `extern` can duplicate imports 36 ex_cls = ex.__class__.__name__ 37 error = ValueError(f"invalid pyproject.toml config: {ex_cls} - {ex}") 38 raise error from None 39 40 _logger.error(f"configuration error: {ex.summary}") # type: ignore 41 _logger.debug(ex.details) # type: ignore 42 error = ValueError(f"invalid pyproject.toml config: {ex.name}") # type: ignore 43 raise error from None 44 45 46def apply_configuration( 47 dist: "Distribution", filepath: _Path, ignore_option_errors=False, 48) -> "Distribution": 49 """Apply the configuration from a ``pyproject.toml`` file into an existing 50 distribution object. 51 """ 52 config = read_configuration(filepath, True, ignore_option_errors, dist) 53 return apply(dist, config, filepath) 54 55 56def read_configuration( 57 filepath: _Path, 58 expand=True, 59 ignore_option_errors=False, 60 dist: Optional["Distribution"] = None, 61): 62 """Read given configuration file and returns options from it as a dict. 63 64 :param str|unicode filepath: Path to configuration file in the ``pyproject.toml`` 65 format. 66 67 :param bool expand: Whether to expand directives and other computed values 68 (i.e. post-process the given configuration) 69 70 :param bool ignore_option_errors: Whether to silently ignore 71 options, values of which could not be resolved (e.g. due to exceptions 72 in directives such as file:, attr:, etc.). 73 If False exceptions are propagated as expected. 74 75 :param Distribution|None: Distribution object to which the configuration refers. 76 If not given a dummy object will be created and discarded after the 77 configuration is read. This is used for auto-discovery of packages in the case 78 a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded. 79 When ``expand=False`` this object is simply ignored. 80 81 :rtype: dict 82 """ 83 filepath = os.path.abspath(filepath) 84 85 if not os.path.isfile(filepath): 86 raise FileError(f"Configuration file {filepath!r} does not exist.") 87 88 asdict = load_file(filepath) or {} 89 project_table = asdict.get("project", {}) 90 tool_table = asdict.get("tool", {}) 91 setuptools_table = tool_table.get("setuptools", {}) 92 if not asdict or not (project_table or setuptools_table): 93 return {} # User is not using pyproject to configure setuptools 94 95 # TODO: Remove once the feature stabilizes 96 msg = ( 97 "Support for project metadata in `pyproject.toml` is still experimental " 98 "and may be removed (or change) in future releases." 99 ) 100 warnings.warn(msg, _ExperimentalProjectMetadata) 101 102 # There is an overall sense in the community that making include_package_data=True 103 # the default would be an improvement. 104 # `ini2toml` backfills include_package_data=False when nothing is explicitly given, 105 # therefore setting a default here is backwards compatible. 106 if dist and getattr(dist, "include_package_data") is not None: 107 setuptools_table.setdefault("include-package-data", dist.include_package_data) 108 else: 109 setuptools_table.setdefault("include-package-data", True) 110 # Persist changes: 111 asdict["tool"] = tool_table 112 tool_table["setuptools"] = setuptools_table 113 114 with _ignore_errors(ignore_option_errors): 115 # Don't complain about unrelated errors (e.g. tools not using the "tool" table) 116 subset = {"project": project_table, "tool": {"setuptools": setuptools_table}} 117 validate(subset, filepath) 118 119 if expand: 120 root_dir = os.path.dirname(filepath) 121 return expand_configuration(asdict, root_dir, ignore_option_errors, dist) 122 123 return asdict 124 125 126def expand_configuration( 127 config: dict, 128 root_dir: Optional[_Path] = None, 129 ignore_option_errors=False, 130 dist: Optional["Distribution"] = None, 131) -> dict: 132 """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...) 133 find their final values. 134 135 :param dict config: Dict containing the configuration for the distribution 136 :param str root_dir: Top-level directory for the distribution/project 137 (the same directory where ``pyproject.toml`` is place) 138 :param bool ignore_option_errors: see :func:`read_configuration` 139 :param Distribution|None: Distribution object to which the configuration refers. 140 If not given a dummy object will be created and discarded after the 141 configuration is read. Used in the case a dynamic configuration 142 (e.g. ``attr`` or ``cmdclass``). 143 144 :rtype: dict 145 """ 146 root_dir = root_dir or os.getcwd() 147 project_cfg = config.get("project", {}) 148 setuptools_cfg = config.get("tool", {}).get("setuptools", {}) 149 ignore = ignore_option_errors 150 151 _expand_packages(setuptools_cfg, root_dir, ignore) 152 _canonic_package_data(setuptools_cfg) 153 _canonic_package_data(setuptools_cfg, "exclude-package-data") 154 155 # A distribution object is required for discovering the correct package_dir 156 dist = _ensure_dist(dist, project_cfg, root_dir) 157 158 with _EnsurePackagesDiscovered(dist, setuptools_cfg) as ensure_discovered: 159 package_dir = ensure_discovered.package_dir 160 process = partial(_process_field, ignore_option_errors=ignore) 161 cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir) 162 data_files = partial(_expand.canonic_data_files, root_dir=root_dir) 163 164 process(setuptools_cfg, "data-files", data_files) 165 process(setuptools_cfg, "cmdclass", cmdclass) 166 _expand_all_dynamic(project_cfg, setuptools_cfg, package_dir, root_dir, ignore) 167 168 return config 169 170 171def _ensure_dist( 172 dist: Optional["Distribution"], project_cfg: dict, root_dir: _Path 173) -> "Distribution": 174 from setuptools.dist import Distribution 175 176 attrs = {"src_root": root_dir, "name": project_cfg.get("name", None)} 177 return dist or Distribution(attrs) 178 179 180class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered): 181 def __init__(self, distribution: "Distribution", setuptools_cfg: dict): 182 super().__init__(distribution) 183 self._setuptools_cfg = setuptools_cfg 184 185 def __enter__(self): 186 """When entering the context, the values of ``packages``, ``py_modules`` and 187 ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``. 188 """ 189 dist, cfg = self._dist, self._setuptools_cfg 190 package_dir: Dict[str, str] = cfg.setdefault("package-dir", {}) 191 package_dir.update(dist.package_dir or {}) 192 dist.package_dir = package_dir # needs to be the same object 193 194 dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour 195 196 # Set `py_modules` and `packages` in dist to short-circuit auto-discovery, 197 # but avoid overwriting empty lists purposefully set by users. 198 if dist.py_modules is None: 199 dist.py_modules = cfg.get("py-modules") 200 if dist.packages is None: 201 dist.packages = cfg.get("packages") 202 203 return super().__enter__() 204 205 def __exit__(self, exc_type, exc_value, traceback): 206 """When exiting the context, if values of ``packages``, ``py_modules`` and 207 ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``. 208 """ 209 # If anything was discovered set them back, so they count in the final config. 210 self._setuptools_cfg.setdefault("packages", self._dist.packages) 211 self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules) 212 return super().__exit__(exc_type, exc_value, traceback) 213 214 215def _expand_all_dynamic( 216 project_cfg: dict, 217 setuptools_cfg: dict, 218 package_dir: Mapping[str, str], 219 root_dir: _Path, 220 ignore_option_errors: bool, 221): 222 ignore = ignore_option_errors 223 dynamic_cfg = setuptools_cfg.get("dynamic", {}) 224 pkg_dir = package_dir 225 special = ( 226 "readme", 227 "version", 228 "entry-points", 229 "scripts", 230 "gui-scripts", 231 "classifiers", 232 ) 233 # readme, version and entry-points need special handling 234 dynamic = project_cfg.get("dynamic", []) 235 regular_dynamic = (x for x in dynamic if x not in special) 236 237 for field in regular_dynamic: 238 value = _expand_dynamic(dynamic_cfg, field, pkg_dir, root_dir, ignore) 239 project_cfg[field] = value 240 241 if "version" in dynamic and "version" in dynamic_cfg: 242 version = _expand_dynamic(dynamic_cfg, "version", pkg_dir, root_dir, ignore) 243 project_cfg["version"] = _expand.version(version) 244 245 if "readme" in dynamic: 246 project_cfg["readme"] = _expand_readme(dynamic_cfg, root_dir, ignore) 247 248 if "entry-points" in dynamic: 249 field = "entry-points" 250 value = _expand_dynamic(dynamic_cfg, field, pkg_dir, root_dir, ignore) 251 project_cfg.update(_expand_entry_points(value, dynamic)) 252 253 if "classifiers" in dynamic: 254 value = _expand_dynamic(dynamic_cfg, "classifiers", pkg_dir, root_dir, ignore) 255 project_cfg["classifiers"] = (value or "").splitlines() 256 257 258def _expand_dynamic( 259 dynamic_cfg: dict, 260 field: str, 261 package_dir: Mapping[str, str], 262 root_dir: _Path, 263 ignore_option_errors: bool, 264): 265 if field in dynamic_cfg: 266 directive = dynamic_cfg[field] 267 with _ignore_errors(ignore_option_errors): 268 if "file" in directive: 269 return _expand.read_files(directive["file"], root_dir) 270 if "attr" in directive: 271 return _expand.read_attr(directive["attr"], package_dir, root_dir) 272 elif not ignore_option_errors: 273 msg = f"Impossible to expand dynamic value of {field!r}. " 274 msg += f"No configuration found for `tool.setuptools.dynamic.{field}`" 275 raise OptionError(msg) 276 return None 277 278 279def _expand_readme( 280 dynamic_cfg: dict, root_dir: _Path, ignore_option_errors: bool 281) -> Dict[str, str]: 282 ignore = ignore_option_errors 283 return { 284 "text": _expand_dynamic(dynamic_cfg, "readme", {}, root_dir, ignore), 285 "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"), 286 } 287 288 289def _expand_entry_points(text: str, dynamic: set): 290 groups = _expand.entry_points(text) 291 expanded = {"entry-points": groups} 292 if "scripts" in dynamic and "console_scripts" in groups: 293 expanded["scripts"] = groups.pop("console_scripts") 294 if "gui-scripts" in dynamic and "gui_scripts" in groups: 295 expanded["gui-scripts"] = groups.pop("gui_scripts") 296 return expanded 297 298 299def _expand_packages(setuptools_cfg: dict, root_dir: _Path, ignore_option_errors=False): 300 packages = setuptools_cfg.get("packages") 301 if packages is None or isinstance(packages, (list, tuple)): 302 return 303 304 find = packages.get("find") 305 if isinstance(find, dict): 306 find["root_dir"] = root_dir 307 find["fill_package_dir"] = setuptools_cfg.setdefault("package-dir", {}) 308 with _ignore_errors(ignore_option_errors): 309 setuptools_cfg["packages"] = _expand.find_packages(**find) 310 311 312def _process_field( 313 container: dict, field: str, fn: Callable, ignore_option_errors=False 314): 315 if field in container: 316 with _ignore_errors(ignore_option_errors): 317 container[field] = fn(container[field]) 318 319 320def _canonic_package_data(setuptools_cfg, field="package-data"): 321 package_data = setuptools_cfg.get(field, {}) 322 return _expand.canonic_package_data(package_data) 323 324 325@contextmanager 326def _ignore_errors(ignore_option_errors: bool): 327 if not ignore_option_errors: 328 yield 329 return 330 331 try: 332 yield 333 except Exception as ex: 334 _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}") 335 336 337class _ExperimentalProjectMetadata(UserWarning): 338 """Explicitly inform users that `pyproject.toml` configuration is experimental""" 339