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 using
7``build.util.project_wheel_metadata`` (https://pypi.org/project/build/).
8For simple scenarios, you can also try parsing the file directly
9with the help of ``tomllib`` or ``tomli``.
10"""
11
12from __future__ import annotations
13
14import logging
15import os
16from collections.abc import Mapping
17from contextlib import contextmanager
18from functools import partial
19from types import TracebackType
20from typing import TYPE_CHECKING, Any, Callable
21
22from .._path import StrPath
23from ..errors import FileError, InvalidConfigError
24from ..warnings import SetuptoolsWarning
25from . import expand as _expand
26from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _MissingDynamic, apply as _apply
27
28if TYPE_CHECKING:
29 from typing_extensions import Self
30
31 from setuptools.dist import Distribution
32
33_logger = logging.getLogger(__name__)
34
35
36def load_file(filepath: StrPath) -> dict:
37 from ..compat.py310 import tomllib
38
39 with open(filepath, "rb") as file:
40 return tomllib.load(file)
41
42
43def validate(config: dict, filepath: StrPath) -> bool:
44 from . import _validate_pyproject as validator
45
46 trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")
47 if hasattr(trove_classifier, "_disable_download"):
48 # Improve reproducibility by default. See abravalheri/validate-pyproject#31
49 trove_classifier._disable_download() # type: ignore[union-attr]
50
51 try:
52 return validator.validate(config)
53 except validator.ValidationError as ex:
54 summary = f"configuration error: {ex.summary}"
55 if ex.name.strip("`") != "project":
56 # Probably it is just a field missing/misnamed, not worthy the verbosity...
57 _logger.debug(summary)
58 _logger.debug(ex.details)
59
60 error = f"invalid pyproject.toml config: {ex.name}."
61 raise ValueError(f"{error}\n{summary}") from None
62
63
64def apply_configuration(
65 dist: Distribution,
66 filepath: StrPath,
67 ignore_option_errors: bool = False,
68) -> Distribution:
69 """Apply the configuration from a ``pyproject.toml`` file into an existing
70 distribution object.
71 """
72 config = read_configuration(filepath, True, ignore_option_errors, dist)
73 return _apply(dist, config, filepath)
74
75
76def read_configuration(
77 filepath: StrPath,
78 expand: bool = True,
79 ignore_option_errors: bool = False,
80 dist: Distribution | None = None,
81) -> dict[str, Any]:
82 """Read given configuration file and returns options from it as a dict.
83
84 :param str|unicode filepath: Path to configuration file in the ``pyproject.toml``
85 format.
86
87 :param bool expand: Whether to expand directives and other computed values
88 (i.e. post-process the given configuration)
89
90 :param bool ignore_option_errors: Whether to silently ignore
91 options, values of which could not be resolved (e.g. due to exceptions
92 in directives such as file:, attr:, etc.).
93 If False exceptions are propagated as expected.
94
95 :param Distribution|None: Distribution object to which the configuration refers.
96 If not given a dummy object will be created and discarded after the
97 configuration is read. This is used for auto-discovery of packages and in the
98 case a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.
99 When ``expand=False`` this object is simply ignored.
100
101 :rtype: dict
102 """
103 filepath = os.path.abspath(filepath)
104
105 if not os.path.isfile(filepath):
106 raise FileError(f"Configuration file {filepath!r} does not exist.")
107
108 asdict = load_file(filepath) or {}
109 project_table = asdict.get("project", {})
110 tool_table = asdict.get("tool", {})
111 setuptools_table = tool_table.get("setuptools", {})
112 if not asdict or not (project_table or setuptools_table):
113 return {} # User is not using pyproject to configure setuptools
114
115 if "setuptools" in asdict.get("tools", {}):
116 # let the user know they probably have a typo in their metadata
117 _ToolsTypoInMetadata.emit()
118
119 if "distutils" in tool_table:
120 _ExperimentalConfiguration.emit(subject="[tool.distutils]")
121
122 # There is an overall sense in the community that making include_package_data=True
123 # the default would be an improvement.
124 # `ini2toml` backfills include_package_data=False when nothing is explicitly given,
125 # therefore setting a default here is backwards compatible.
126 if dist and dist.include_package_data is not None:
127 setuptools_table.setdefault("include-package-data", dist.include_package_data)
128 else:
129 setuptools_table.setdefault("include-package-data", True)
130 # Persist changes:
131 asdict["tool"] = tool_table
132 tool_table["setuptools"] = setuptools_table
133
134 if "ext-modules" in setuptools_table:
135 _ExperimentalConfiguration.emit(subject="[tool.setuptools.ext-modules]")
136
137 with _ignore_errors(ignore_option_errors):
138 # Don't complain about unrelated errors (e.g. tools not using the "tool" table)
139 subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}
140 validate(subset, filepath)
141
142 if expand:
143 root_dir = os.path.dirname(filepath)
144 return expand_configuration(asdict, root_dir, ignore_option_errors, dist)
145
146 return asdict
147
148
149def expand_configuration(
150 config: dict,
151 root_dir: StrPath | None = None,
152 ignore_option_errors: bool = False,
153 dist: Distribution | None = None,
154) -> dict:
155 """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)
156 find their final values.
157
158 :param dict config: Dict containing the configuration for the distribution
159 :param str root_dir: Top-level directory for the distribution/project
160 (the same directory where ``pyproject.toml`` is place)
161 :param bool ignore_option_errors: see :func:`read_configuration`
162 :param Distribution|None: Distribution object to which the configuration refers.
163 If not given a dummy object will be created and discarded after the
164 configuration is read. Used in the case a dynamic configuration
165 (e.g. ``attr`` or ``cmdclass``).
166
167 :rtype: dict
168 """
169 return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()
170
171
172class _ConfigExpander:
173 def __init__(
174 self,
175 config: dict,
176 root_dir: StrPath | None = None,
177 ignore_option_errors: bool = False,
178 dist: Distribution | None = None,
179 ) -> None:
180 self.config = config
181 self.root_dir = root_dir or os.getcwd()
182 self.project_cfg = config.get("project", {})
183 self.dynamic = self.project_cfg.get("dynamic", [])
184 self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})
185 self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})
186 self.ignore_option_errors = ignore_option_errors
187 self._dist = dist
188 self._referenced_files = set[str]()
189
190 def _ensure_dist(self) -> Distribution:
191 from setuptools.dist import Distribution
192
193 attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}
194 return self._dist or Distribution(attrs)
195
196 def _process_field(self, container: dict, field: str, fn: Callable):
197 if field in container:
198 with _ignore_errors(self.ignore_option_errors):
199 container[field] = fn(container[field])
200
201 def _canonic_package_data(self, field="package-data"):
202 package_data = self.setuptools_cfg.get(field, {})
203 return _expand.canonic_package_data(package_data)
204
205 def expand(self):
206 self._expand_packages()
207 self._canonic_package_data()
208 self._canonic_package_data("exclude-package-data")
209
210 # A distribution object is required for discovering the correct package_dir
211 dist = self._ensure_dist()
212 ctx = _EnsurePackagesDiscovered(dist, self.project_cfg, self.setuptools_cfg)
213 with ctx as ensure_discovered:
214 package_dir = ensure_discovered.package_dir
215 self._expand_data_files()
216 self._expand_cmdclass(package_dir)
217 self._expand_all_dynamic(dist, package_dir)
218
219 dist._referenced_files.update(self._referenced_files)
220 return self.config
221
222 def _expand_packages(self):
223 packages = self.setuptools_cfg.get("packages")
224 if packages is None or isinstance(packages, (list, tuple)):
225 return
226
227 find = packages.get("find")
228 if isinstance(find, dict):
229 find["root_dir"] = self.root_dir
230 find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})
231 with _ignore_errors(self.ignore_option_errors):
232 self.setuptools_cfg["packages"] = _expand.find_packages(**find)
233
234 def _expand_data_files(self):
235 data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)
236 self._process_field(self.setuptools_cfg, "data-files", data_files)
237
238 def _expand_cmdclass(self, package_dir: Mapping[str, str]):
239 root_dir = self.root_dir
240 cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)
241 self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)
242
243 def _expand_all_dynamic(self, dist: Distribution, package_dir: Mapping[str, str]):
244 special = ( # need special handling
245 "version",
246 "readme",
247 "entry-points",
248 "scripts",
249 "gui-scripts",
250 "classifiers",
251 "dependencies",
252 "optional-dependencies",
253 )
254 # `_obtain` functions are assumed to raise appropriate exceptions/warnings.
255 obtained_dynamic = {
256 field: self._obtain(dist, field, package_dir)
257 for field in self.dynamic
258 if field not in special
259 }
260 obtained_dynamic.update(
261 self._obtain_entry_points(dist, package_dir) or {},
262 version=self._obtain_version(dist, package_dir),
263 readme=self._obtain_readme(dist),
264 classifiers=self._obtain_classifiers(dist),
265 dependencies=self._obtain_dependencies(dist),
266 optional_dependencies=self._obtain_optional_dependencies(dist),
267 )
268 # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
269 # might have already been set by setup.py/extensions, so avoid overwriting.
270 updates = {k: v for k, v in obtained_dynamic.items() if v is not None}
271 self.project_cfg.update(updates)
272
273 def _ensure_previously_set(self, dist: Distribution, field: str):
274 previous = _PREVIOUSLY_DEFINED[field](dist)
275 if previous is None and not self.ignore_option_errors:
276 msg = (
277 f"No configuration found for dynamic {field!r}.\n"
278 "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"
279 "\nothers must be specified via the equivalent attribute in `setup.py`."
280 )
281 raise InvalidConfigError(msg)
282
283 def _expand_directive(
284 self, specifier: str, directive, package_dir: Mapping[str, str]
285 ):
286 from more_itertools import always_iterable
287
288 with _ignore_errors(self.ignore_option_errors):
289 root_dir = self.root_dir
290 if "file" in directive:
291 self._referenced_files.update(always_iterable(directive["file"]))
292 return _expand.read_files(directive["file"], root_dir)
293 if "attr" in directive:
294 return _expand.read_attr(directive["attr"], package_dir, root_dir)
295 raise ValueError(f"invalid `{specifier}`: {directive!r}")
296 return None
297
298 def _obtain(self, dist: Distribution, field: str, package_dir: Mapping[str, str]):
299 if field in self.dynamic_cfg:
300 return self._expand_directive(
301 f"tool.setuptools.dynamic.{field}",
302 self.dynamic_cfg[field],
303 package_dir,
304 )
305 self._ensure_previously_set(dist, field)
306 return None
307
308 def _obtain_version(self, dist: Distribution, package_dir: Mapping[str, str]):
309 # Since plugins can set version, let's silently skip if it cannot be obtained
310 if "version" in self.dynamic and "version" in self.dynamic_cfg:
311 return _expand.version(
312 # We already do an early check for the presence of "version"
313 self._obtain(dist, "version", package_dir) # pyright: ignore[reportArgumentType]
314 )
315 return None
316
317 def _obtain_readme(self, dist: Distribution) -> dict[str, str] | None:
318 if "readme" not in self.dynamic:
319 return None
320
321 dynamic_cfg = self.dynamic_cfg
322 if "readme" in dynamic_cfg:
323 return {
324 # We already do an early check for the presence of "readme"
325 "text": self._obtain(dist, "readme", {}),
326 "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
327 } # pyright: ignore[reportReturnType]
328
329 self._ensure_previously_set(dist, "readme")
330 return None
331
332 def _obtain_entry_points(
333 self, dist: Distribution, package_dir: Mapping[str, str]
334 ) -> dict[str, dict[str, Any]] | None:
335 fields = ("entry-points", "scripts", "gui-scripts")
336 if not any(field in self.dynamic for field in fields):
337 return None
338
339 text = self._obtain(dist, "entry-points", package_dir)
340 if text is None:
341 return None
342
343 groups = _expand.entry_points(text)
344 # Any is str | dict[str, str], but causes variance issues
345 expanded: dict[str, dict[str, Any]] = {"entry-points": groups}
346
347 def _set_scripts(field: str, group: str):
348 if group in groups:
349 value = groups.pop(group)
350 if field not in self.dynamic:
351 raise InvalidConfigError(_MissingDynamic.details(field, value))
352 expanded[field] = value
353
354 _set_scripts("scripts", "console_scripts")
355 _set_scripts("gui-scripts", "gui_scripts")
356
357 return expanded
358
359 def _obtain_classifiers(self, dist: Distribution):
360 if "classifiers" in self.dynamic:
361 value = self._obtain(dist, "classifiers", {})
362 if value:
363 return value.splitlines()
364 return None
365
366 def _obtain_dependencies(self, dist: Distribution):
367 if "dependencies" in self.dynamic:
368 value = self._obtain(dist, "dependencies", {})
369 if value:
370 return _parse_requirements_list(value)
371 return None
372
373 def _obtain_optional_dependencies(self, dist: Distribution):
374 if "optional-dependencies" not in self.dynamic:
375 return None
376 if "optional-dependencies" in self.dynamic_cfg:
377 optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]
378 assert isinstance(optional_dependencies_map, dict)
379 return {
380 group: _parse_requirements_list(
381 self._expand_directive(
382 f"tool.setuptools.dynamic.optional-dependencies.{group}",
383 directive,
384 {},
385 )
386 )
387 for group, directive in optional_dependencies_map.items()
388 }
389 self._ensure_previously_set(dist, "optional-dependencies")
390 return None
391
392
393def _parse_requirements_list(value):
394 return [
395 line
396 for line in value.splitlines()
397 if line.strip() and not line.strip().startswith("#")
398 ]
399
400
401@contextmanager
402def _ignore_errors(ignore_option_errors: bool):
403 if not ignore_option_errors:
404 yield
405 return
406
407 try:
408 yield
409 except Exception as ex:
410 _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
411
412
413class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):
414 def __init__(
415 self, distribution: Distribution, project_cfg: dict, setuptools_cfg: dict
416 ) -> None:
417 super().__init__(distribution)
418 self._project_cfg = project_cfg
419 self._setuptools_cfg = setuptools_cfg
420
421 def __enter__(self) -> Self:
422 """When entering the context, the values of ``packages``, ``py_modules`` and
423 ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.
424 """
425 dist, cfg = self._dist, self._setuptools_cfg
426 package_dir: dict[str, str] = cfg.setdefault("package-dir", {})
427 package_dir.update(dist.package_dir or {})
428 dist.package_dir = package_dir # needs to be the same object
429
430 dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour
431
432 # Set `name`, `py_modules` and `packages` in dist to short-circuit
433 # auto-discovery, but avoid overwriting empty lists purposefully set by users.
434 if dist.metadata.name is None:
435 dist.metadata.name = self._project_cfg.get("name")
436 if dist.py_modules is None:
437 dist.py_modules = cfg.get("py-modules")
438 if dist.packages is None:
439 dist.packages = cfg.get("packages")
440
441 return super().__enter__()
442
443 def __exit__(
444 self,
445 exc_type: type[BaseException] | None,
446 exc_value: BaseException | None,
447 traceback: TracebackType | None,
448 ) -> None:
449 """When exiting the context, if values of ``packages``, ``py_modules`` and
450 ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.
451 """
452 # If anything was discovered set them back, so they count in the final config.
453 self._setuptools_cfg.setdefault("packages", self._dist.packages)
454 self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)
455 return super().__exit__(exc_type, exc_value, traceback)
456
457
458class _ExperimentalConfiguration(SetuptoolsWarning):
459 _SUMMARY = (
460 "`{subject}` in `pyproject.toml` is still *experimental* "
461 "and likely to change in future releases."
462 )
463
464
465class _ToolsTypoInMetadata(SetuptoolsWarning):
466 _SUMMARY = (
467 "Ignoring [tools.setuptools] in pyproject.toml, did you mean [tool.setuptools]?"
468 )