pyprojecttoml.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. """
  2. Load setuptools configuration from ``pyproject.toml`` files.
  3. **PRIVATE MODULE**: API reserved for setuptools internal usage only.
  4. """
  5. import logging
  6. import os
  7. import warnings
  8. from contextlib import contextmanager
  9. from functools import partial
  10. from typing import TYPE_CHECKING, Callable, Dict, Optional, Mapping, Union
  11. from setuptools.errors import FileError, OptionError
  12. from . import expand as _expand
  13. from ._apply_pyprojecttoml import apply as _apply
  14. from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _WouldIgnoreField
  15. if TYPE_CHECKING:
  16. from setuptools.dist import Distribution # noqa
  17. _Path = Union[str, os.PathLike]
  18. _logger = logging.getLogger(__name__)
  19. def load_file(filepath: _Path) -> dict:
  20. from setuptools.extern import tomli # type: ignore
  21. with open(filepath, "rb") as file:
  22. return tomli.load(file)
  23. def validate(config: dict, filepath: _Path) -> bool:
  24. from . import _validate_pyproject as validator
  25. trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")
  26. if hasattr(trove_classifier, "_disable_download"):
  27. # Improve reproducibility by default. See issue 31 for validate-pyproject.
  28. trove_classifier._disable_download() # type: ignore
  29. try:
  30. return validator.validate(config)
  31. except validator.ValidationError as ex:
  32. _logger.error(f"configuration error: {ex.summary}") # type: ignore
  33. _logger.debug(ex.details) # type: ignore
  34. error = ValueError(f"invalid pyproject.toml config: {ex.name}") # type: ignore
  35. raise error from None
  36. def apply_configuration(
  37. dist: "Distribution",
  38. filepath: _Path,
  39. ignore_option_errors=False,
  40. ) -> "Distribution":
  41. """Apply the configuration from a ``pyproject.toml`` file into an existing
  42. distribution object.
  43. """
  44. config = read_configuration(filepath, True, ignore_option_errors, dist)
  45. return _apply(dist, config, filepath)
  46. def read_configuration(
  47. filepath: _Path,
  48. expand=True,
  49. ignore_option_errors=False,
  50. dist: Optional["Distribution"] = None,
  51. ):
  52. """Read given configuration file and returns options from it as a dict.
  53. :param str|unicode filepath: Path to configuration file in the ``pyproject.toml``
  54. format.
  55. :param bool expand: Whether to expand directives and other computed values
  56. (i.e. post-process the given configuration)
  57. :param bool ignore_option_errors: Whether to silently ignore
  58. options, values of which could not be resolved (e.g. due to exceptions
  59. in directives such as file:, attr:, etc.).
  60. If False exceptions are propagated as expected.
  61. :param Distribution|None: Distribution object to which the configuration refers.
  62. If not given a dummy object will be created and discarded after the
  63. configuration is read. This is used for auto-discovery of packages in the case
  64. a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.
  65. When ``expand=False`` this object is simply ignored.
  66. :rtype: dict
  67. """
  68. filepath = os.path.abspath(filepath)
  69. if not os.path.isfile(filepath):
  70. raise FileError(f"Configuration file {filepath!r} does not exist.")
  71. asdict = load_file(filepath) or {}
  72. project_table = asdict.get("project", {})
  73. tool_table = asdict.get("tool", {})
  74. setuptools_table = tool_table.get("setuptools", {})
  75. if not asdict or not (project_table or setuptools_table):
  76. return {} # User is not using pyproject to configure setuptools
  77. if setuptools_table:
  78. # TODO: Remove the following once the feature stabilizes:
  79. msg = "Support for `[tool.setuptools]` in `pyproject.toml` is still *beta*."
  80. warnings.warn(msg, _BetaConfiguration)
  81. # There is an overall sense in the community that making include_package_data=True
  82. # the default would be an improvement.
  83. # `ini2toml` backfills include_package_data=False when nothing is explicitly given,
  84. # therefore setting a default here is backwards compatible.
  85. orig_setuptools_table = setuptools_table.copy()
  86. if dist and getattr(dist, "include_package_data") is not None:
  87. setuptools_table.setdefault("include-package-data", dist.include_package_data)
  88. else:
  89. setuptools_table.setdefault("include-package-data", True)
  90. # Persist changes:
  91. asdict["tool"] = tool_table
  92. tool_table["setuptools"] = setuptools_table
  93. try:
  94. # Don't complain about unrelated errors (e.g. tools not using the "tool" table)
  95. subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}
  96. validate(subset, filepath)
  97. except Exception as ex:
  98. # TODO: Remove the following once the feature stabilizes:
  99. if _skip_bad_config(project_table, orig_setuptools_table, dist):
  100. return {}
  101. # TODO: After the previous statement is removed the try/except can be replaced
  102. # by the _ignore_errors context manager.
  103. if ignore_option_errors:
  104. _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
  105. else:
  106. raise # re-raise exception
  107. if expand:
  108. root_dir = os.path.dirname(filepath)
  109. return expand_configuration(asdict, root_dir, ignore_option_errors, dist)
  110. return asdict
  111. def _skip_bad_config(
  112. project_cfg: dict, setuptools_cfg: dict, dist: Optional["Distribution"]
  113. ) -> bool:
  114. """Be temporarily forgiving with invalid ``pyproject.toml``"""
  115. # See pypa/setuptools#3199 and pypa/cibuildwheel#1064
  116. if dist is None or (
  117. dist.metadata.name is None
  118. and dist.metadata.version is None
  119. and dist.install_requires is None
  120. ):
  121. # It seems that the build is not getting any configuration from other places
  122. return False
  123. if setuptools_cfg:
  124. # If `[tool.setuptools]` is set, then `pyproject.toml` config is intentional
  125. return False
  126. given_config = set(project_cfg.keys())
  127. popular_subset = {"name", "version", "python_requires", "requires-python"}
  128. if given_config <= popular_subset:
  129. # It seems that the docs in cibuildtool has been inadvertently encouraging users
  130. # to create `pyproject.toml` files that are not compliant with the standards.
  131. # Let's be forgiving for the time being.
  132. warnings.warn(_InvalidFile.message(), _InvalidFile, stacklevel=2)
  133. return True
  134. return False
  135. def expand_configuration(
  136. config: dict,
  137. root_dir: Optional[_Path] = None,
  138. ignore_option_errors: bool = False,
  139. dist: Optional["Distribution"] = None,
  140. ) -> dict:
  141. """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)
  142. find their final values.
  143. :param dict config: Dict containing the configuration for the distribution
  144. :param str root_dir: Top-level directory for the distribution/project
  145. (the same directory where ``pyproject.toml`` is place)
  146. :param bool ignore_option_errors: see :func:`read_configuration`
  147. :param Distribution|None: Distribution object to which the configuration refers.
  148. If not given a dummy object will be created and discarded after the
  149. configuration is read. Used in the case a dynamic configuration
  150. (e.g. ``attr`` or ``cmdclass``).
  151. :rtype: dict
  152. """
  153. return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()
  154. class _ConfigExpander:
  155. def __init__(
  156. self,
  157. config: dict,
  158. root_dir: Optional[_Path] = None,
  159. ignore_option_errors: bool = False,
  160. dist: Optional["Distribution"] = None,
  161. ):
  162. self.config = config
  163. self.root_dir = root_dir or os.getcwd()
  164. self.project_cfg = config.get("project", {})
  165. self.dynamic = self.project_cfg.get("dynamic", [])
  166. self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})
  167. self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})
  168. self.ignore_option_errors = ignore_option_errors
  169. self._dist = dist
  170. def _ensure_dist(self) -> "Distribution":
  171. from setuptools.dist import Distribution
  172. attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}
  173. return self._dist or Distribution(attrs)
  174. def _process_field(self, container: dict, field: str, fn: Callable):
  175. if field in container:
  176. with _ignore_errors(self.ignore_option_errors):
  177. container[field] = fn(container[field])
  178. def _canonic_package_data(self, field="package-data"):
  179. package_data = self.setuptools_cfg.get(field, {})
  180. return _expand.canonic_package_data(package_data)
  181. def expand(self):
  182. self._expand_packages()
  183. self._canonic_package_data()
  184. self._canonic_package_data("exclude-package-data")
  185. # A distribution object is required for discovering the correct package_dir
  186. dist = self._ensure_dist()
  187. with _EnsurePackagesDiscovered(dist, self.setuptools_cfg) as ensure_discovered:
  188. package_dir = ensure_discovered.package_dir
  189. self._expand_data_files()
  190. self._expand_cmdclass(package_dir)
  191. self._expand_all_dynamic(dist, package_dir)
  192. return self.config
  193. def _expand_packages(self):
  194. packages = self.setuptools_cfg.get("packages")
  195. if packages is None or isinstance(packages, (list, tuple)):
  196. return
  197. find = packages.get("find")
  198. if isinstance(find, dict):
  199. find["root_dir"] = self.root_dir
  200. find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})
  201. with _ignore_errors(self.ignore_option_errors):
  202. self.setuptools_cfg["packages"] = _expand.find_packages(**find)
  203. def _expand_data_files(self):
  204. data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)
  205. self._process_field(self.setuptools_cfg, "data-files", data_files)
  206. def _expand_cmdclass(self, package_dir: Mapping[str, str]):
  207. root_dir = self.root_dir
  208. cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)
  209. self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)
  210. def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, str]):
  211. special = ( # need special handling
  212. "version",
  213. "readme",
  214. "entry-points",
  215. "scripts",
  216. "gui-scripts",
  217. "classifiers",
  218. "dependencies",
  219. "optional-dependencies",
  220. )
  221. # `_obtain` functions are assumed to raise appropriate exceptions/warnings.
  222. obtained_dynamic = {
  223. field: self._obtain(dist, field, package_dir)
  224. for field in self.dynamic
  225. if field not in special
  226. }
  227. obtained_dynamic.update(
  228. self._obtain_entry_points(dist, package_dir) or {},
  229. version=self._obtain_version(dist, package_dir),
  230. readme=self._obtain_readme(dist),
  231. classifiers=self._obtain_classifiers(dist),
  232. dependencies=self._obtain_dependencies(dist),
  233. optional_dependencies=self._obtain_optional_dependencies(dist),
  234. )
  235. # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
  236. # might have already been set by setup.py/extensions, so avoid overwriting.
  237. updates = {k: v for k, v in obtained_dynamic.items() if v is not None}
  238. self.project_cfg.update(updates)
  239. def _ensure_previously_set(self, dist: "Distribution", field: str):
  240. previous = _PREVIOUSLY_DEFINED[field](dist)
  241. if previous is None and not self.ignore_option_errors:
  242. msg = (
  243. f"No configuration found for dynamic {field!r}.\n"
  244. "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"
  245. "\nothers must be specified via the equivalent attribute in `setup.py`."
  246. )
  247. raise OptionError(msg)
  248. def _expand_directive(
  249. self, specifier: str, directive, package_dir: Mapping[str, str]
  250. ):
  251. with _ignore_errors(self.ignore_option_errors):
  252. root_dir = self.root_dir
  253. if "file" in directive:
  254. return _expand.read_files(directive["file"], root_dir)
  255. if "attr" in directive:
  256. return _expand.read_attr(directive["attr"], package_dir, root_dir)
  257. raise ValueError(f"invalid `{specifier}`: {directive!r}")
  258. return None
  259. def _obtain(self, dist: "Distribution", field: str, package_dir: Mapping[str, str]):
  260. if field in self.dynamic_cfg:
  261. return self._expand_directive(
  262. f"tool.setuptools.dynamic.{field}",
  263. self.dynamic_cfg[field],
  264. package_dir,
  265. )
  266. self._ensure_previously_set(dist, field)
  267. return None
  268. def _obtain_version(self, dist: "Distribution", package_dir: Mapping[str, str]):
  269. # Since plugins can set version, let's silently skip if it cannot be obtained
  270. if "version" in self.dynamic and "version" in self.dynamic_cfg:
  271. return _expand.version(self._obtain(dist, "version", package_dir))
  272. return None
  273. def _obtain_readme(self, dist: "Distribution") -> Optional[Dict[str, str]]:
  274. if "readme" not in self.dynamic:
  275. return None
  276. dynamic_cfg = self.dynamic_cfg
  277. if "readme" in dynamic_cfg:
  278. return {
  279. "text": self._obtain(dist, "readme", {}),
  280. "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
  281. }
  282. self._ensure_previously_set(dist, "readme")
  283. return None
  284. def _obtain_entry_points(
  285. self, dist: "Distribution", package_dir: Mapping[str, str]
  286. ) -> Optional[Dict[str, dict]]:
  287. fields = ("entry-points", "scripts", "gui-scripts")
  288. if not any(field in self.dynamic for field in fields):
  289. return None
  290. text = self._obtain(dist, "entry-points", package_dir)
  291. if text is None:
  292. return None
  293. groups = _expand.entry_points(text)
  294. expanded = {"entry-points": groups}
  295. def _set_scripts(field: str, group: str):
  296. if group in groups:
  297. value = groups.pop(group)
  298. if field not in self.dynamic:
  299. msg = _WouldIgnoreField.message(field, value)
  300. warnings.warn(msg, _WouldIgnoreField)
  301. # TODO: Don't set field when support for pyproject.toml stabilizes
  302. # instead raise an error as specified in PEP 621
  303. expanded[field] = value
  304. _set_scripts("scripts", "console_scripts")
  305. _set_scripts("gui-scripts", "gui_scripts")
  306. return expanded
  307. def _obtain_classifiers(self, dist: "Distribution"):
  308. if "classifiers" in self.dynamic:
  309. value = self._obtain(dist, "classifiers", {})
  310. if value:
  311. return value.splitlines()
  312. return None
  313. def _obtain_dependencies(self, dist: "Distribution"):
  314. if "dependencies" in self.dynamic:
  315. value = self._obtain(dist, "dependencies", {})
  316. if value:
  317. return _parse_requirements_list(value)
  318. return None
  319. def _obtain_optional_dependencies(self, dist: "Distribution"):
  320. if "optional-dependencies" not in self.dynamic:
  321. return None
  322. if "optional-dependencies" in self.dynamic_cfg:
  323. optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]
  324. assert isinstance(optional_dependencies_map, dict)
  325. return {
  326. group: _parse_requirements_list(self._expand_directive(
  327. f"tool.setuptools.dynamic.optional-dependencies.{group}",
  328. directive,
  329. {},
  330. ))
  331. for group, directive in optional_dependencies_map.items()
  332. }
  333. self._ensure_previously_set(dist, "optional-dependencies")
  334. return None
  335. def _parse_requirements_list(value):
  336. return [
  337. line
  338. for line in value.splitlines()
  339. if line.strip() and not line.strip().startswith("#")
  340. ]
  341. @contextmanager
  342. def _ignore_errors(ignore_option_errors: bool):
  343. if not ignore_option_errors:
  344. yield
  345. return
  346. try:
  347. yield
  348. except Exception as ex:
  349. _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
  350. class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):
  351. def __init__(self, distribution: "Distribution", setuptools_cfg: dict):
  352. super().__init__(distribution)
  353. self._setuptools_cfg = setuptools_cfg
  354. def __enter__(self):
  355. """When entering the context, the values of ``packages``, ``py_modules`` and
  356. ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.
  357. """
  358. dist, cfg = self._dist, self._setuptools_cfg
  359. package_dir: Dict[str, str] = cfg.setdefault("package-dir", {})
  360. package_dir.update(dist.package_dir or {})
  361. dist.package_dir = package_dir # needs to be the same object
  362. dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour
  363. # Set `py_modules` and `packages` in dist to short-circuit auto-discovery,
  364. # but avoid overwriting empty lists purposefully set by users.
  365. if dist.py_modules is None:
  366. dist.py_modules = cfg.get("py-modules")
  367. if dist.packages is None:
  368. dist.packages = cfg.get("packages")
  369. return super().__enter__()
  370. def __exit__(self, exc_type, exc_value, traceback):
  371. """When exiting the context, if values of ``packages``, ``py_modules`` and
  372. ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.
  373. """
  374. # If anything was discovered set them back, so they count in the final config.
  375. self._setuptools_cfg.setdefault("packages", self._dist.packages)
  376. self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)
  377. return super().__exit__(exc_type, exc_value, traceback)
  378. class _BetaConfiguration(UserWarning):
  379. """Explicitly inform users that some `pyproject.toml` configuration is *beta*"""
  380. class _InvalidFile(UserWarning):
  381. """The given `pyproject.toml` file is invalid and would be ignored.
  382. !!\n\n
  383. ############################
  384. # Invalid `pyproject.toml` #
  385. ############################
  386. Any configurations in `pyproject.toml` will be ignored.
  387. Please note that future releases of setuptools will halt the build process
  388. if an invalid file is given.
  389. To prevent setuptools from considering `pyproject.toml` please
  390. DO NOT include the `[project]` or `[tool.setuptools]` tables in your file.
  391. \n\n!!
  392. """
  393. @classmethod
  394. def message(cls):
  395. from inspect import cleandoc
  396. return cleandoc(cls.__doc__)